From e96ffd43e75b88663af026140d04f9c9fdbbd381 Mon Sep 17 00:00:00 2001
From: Min Idzelis <min123@gmail.com>
Date: Tue, 18 Mar 2025 10:14:46 -0400
Subject: [PATCH] feat: timeline performance (#16446)

* Squash - feature complete

* remove need to init assetstore

* More optimizations. No need to init. Fix tests

* lint

* add missing selector for e2e

* e2e selectors again

* Update: fully reactive store, some transitions, bugfixes

* merge fallout

* Test fallout

* safari quirk

* security

* lint

* lint

* Bug fixes

* lint/format

* accidental commit

* lock

* null check, more throttle

* revert long duration

* Fix intersection bounds

* Fix bugs in intersection calculation

* lint, tweak scrubber ui a tiny bit

* bugfix - deselecting asset doesnt work

* fix not loading bucket, scroll off-by-1 error, jsdoc, naming
---
 e2e/src/web/specs/shared-link.e2e-spec.ts     |    2 +-
 web/package-lock.json                         |   10 +-
 web/package.json                              |    6 +-
 web/src/app.css                               |   25 +-
 web/src/lib/actions/intersection-observer.ts  |   12 +-
 web/src/lib/actions/resize-observer.ts        |    2 +-
 .../components/album-page/album-viewer.svelte |    8 +-
 .../asset-viewer/asset-viewer.svelte          |    2 +-
 .../assets/thumbnail/image-thumbnail.svelte   |   17 +-
 .../assets/thumbnail/thumbnail.svelte         |  420 ++---
 .../assets/thumbnail/video-thumbnail.svelte   |   40 +-
 .../photos-page/asset-date-group.svelte       |  305 ++-
 .../components/photos-page/asset-grid.svelte  |  497 ++---
 .../photos-page/measure-date-group.svelte     |   91 -
 .../components/photos-page/skeleton.svelte    |   32 +-
 .../shared-components/control-app-bar.svelte  |    2 +-
 .../gallery-viewer/gallery-viewer.svelte      |  199 +-
 .../scrubber/scrubber.svelte                  |   97 +-
 .../side-bar/purchase-info.svelte             |   13 +-
 .../shared-components/tree/breadcrumbs.svelte |    2 +-
 .../duplicates-compare-control.svelte         |    2 +-
 web/src/lib/constants.ts                      |   27 +-
 .../lib/stores/asset-interaction.svelte.ts    |   11 +-
 web/src/lib/stores/assets-store.spec.ts       |  219 ++-
 web/src/lib/stores/assets-store.svelte.ts     | 1651 ++++++++++-------
 web/src/lib/stores/timeline.store.ts          |    3 -
 web/src/lib/utils/asset-store-task-manager.ts |  465 -----
 web/src/lib/utils/asset-utils.ts              |    2 +-
 web/src/lib/utils/cancellable-task.ts         |  135 ++
 web/src/lib/utils/idle-callback-support.ts    |   22 -
 web/src/lib/utils/keyed-priority-queue.ts     |   50 -
 web/src/lib/utils/layout-utils.ts             |   46 +-
 web/src/lib/utils/priority-queue.ts           |   21 -
 web/src/lib/utils/timeline-util.ts            |   87 +-
 web/src/lib/utils/tunables.ts                 |   45 +-
 .../[[assetId=id]]/+page.svelte               |  278 +--
 .../[[assetId=id]]/+page.svelte               |   29 +-
 .../[[assetId=id]]/+page.svelte               |    9 +-
 .../[[assetId=id]]/+page.svelte               |   14 +-
 .../[[assetId=id]]/+page.svelte               |    2 +-
 .../[[assetId=id]]/+page.svelte               |    8 +-
 web/src/routes/(user)/people/+page.svelte     |    6 +-
 .../[[assetId=id]]/+page.svelte               |   23 +-
 .../(user)/photos/[[assetId=id]]/+page.svelte |   18 +-
 .../[[assetId=id]]/+page.svelte               |   97 +-
 .../[[assetId=id]]/+page.svelte               |   10 +-
 .../[[assetId=id]]/+page.svelte               |   18 +-
 web/tsconfig.json                             |    2 +-
 48 files changed, 2318 insertions(+), 2764 deletions(-)
 delete mode 100644 web/src/lib/components/photos-page/measure-date-group.svelte
 delete mode 100644 web/src/lib/stores/timeline.store.ts
 delete mode 100644 web/src/lib/utils/asset-store-task-manager.ts
 create mode 100644 web/src/lib/utils/cancellable-task.ts
 delete mode 100644 web/src/lib/utils/idle-callback-support.ts
 delete mode 100644 web/src/lib/utils/keyed-priority-queue.ts
 delete mode 100644 web/src/lib/utils/priority-queue.ts

diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts
index ed81db4ef5..9313526dab 100644
--- a/e2e/src/web/specs/shared-link.e2e-spec.ts
+++ b/e2e/src/web/specs/shared-link.e2e-spec.ts
@@ -45,7 +45,7 @@ test.describe('Shared Links', () => {
     await page.goto(`/share/${sharedLink.key}`);
     await page.getByRole('heading', { name: 'Test Album' }).waitFor();
     await page.locator(`[data-asset-id="${asset.id}"]`).hover();
-    await page.waitForSelector('#asset-group-by-date svg');
+    await page.waitForSelector('[data-group] svg');
     await page.getByRole('checkbox').click();
     await page.getByRole('button', { name: 'Download' }).click();
     await page.getByText('DOWNLOADING', { exact: true }).waitFor();
diff --git a/web/package-lock.json b/web/package-lock.json
index 6b2a0e9bfb..a2da0e1ae9 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -70,8 +70,8 @@
         "prettier-plugin-sort-json": "^4.1.1",
         "prettier-plugin-svelte": "^3.3.3",
         "rollup-plugin-visualizer": "^5.14.0",
-        "svelte": "^5.17.4",
-        "svelte-check": "^4.1.4",
+        "svelte": "^5.22.6",
+        "svelte-check": "^4.1.5",
         "tailwindcss": "^3.4.17",
         "tslib": "^2.6.2",
         "typescript": "^5.7.3",
@@ -9579,9 +9579,9 @@
       }
     },
     "node_modules/vite": {
-      "version": "6.2.1",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
-      "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
+      "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
diff --git a/web/package.json b/web/package.json
index af45e08659..77da2d24cb 100644
--- a/web/package.json
+++ b/web/package.json
@@ -8,7 +8,7 @@
     "build:stats": "BUILD_STATS=true vite build",
     "package": "svelte-kit package",
     "preview": "vite preview",
-    "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore'",
+    "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte",
     "check:typescript": "tsc --noEmit",
     "check:watch": "npm run check:svelte -- --watch",
     "check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript",
@@ -56,8 +56,8 @@
     "prettier-plugin-sort-json": "^4.1.1",
     "prettier-plugin-svelte": "^3.3.3",
     "rollup-plugin-visualizer": "^5.14.0",
-    "svelte": "^5.17.4",
-    "svelte-check": "^4.1.4",
+    "svelte": "^5.22.6",
+    "svelte-check": "^4.1.5",
     "tailwindcss": "^3.4.17",
     "tslib": "^2.6.2",
     "typescript": "^5.7.3",
diff --git a/web/src/app.css b/web/src/app.css
index 9bc1695a8f..a256cc9d80 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -135,32 +135,13 @@ input:focus-visible {
   }
 
   /* width */
-  .immich-scrollbar::-webkit-scrollbar {
-    width: 8px;
-  }
-
-  /* Track */
-  .immich-scrollbar::-webkit-scrollbar-track {
-    background: #f1f1f1;
-    border-radius: 16px;
-  }
-
-  /* Handle */
-  .immich-scrollbar::-webkit-scrollbar-thumb {
-    background: rgba(85, 86, 87, 0.408);
-    border-radius: 16px;
-  }
-
-  /* Handle on hover */
-  .immich-scrollbar::-webkit-scrollbar-thumb:hover {
-    background: #4250afad;
-    border-radius: 16px;
+  .immich-scrollbar {
+    scrollbar-width: thin;
   }
 
   /* Hidden scrollbar */
   /* width */
-  .scrollbar-hidden::-webkit-scrollbar {
-    display: none;
+  .scrollbar-hidden {
     scrollbar-width: none;
   }
 
diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts
index 3a10074051..74643aa95d 100644
--- a/web/src/lib/actions/intersection-observer.ts
+++ b/web/src/lib/actions/intersection-observer.ts
@@ -13,6 +13,7 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem
 type OnSeparateCallback = (element: HTMLElement) => unknown;
 type IntersectionObserverActionProperties = {
   key?: string;
+  disabled?: boolean;
   /** Function to execute when the element leaves the viewport */
   onSeparate?: OnSeparateCallback;
   /** Function to execute when the element enters the viewport */
@@ -83,8 +84,15 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int
 };
 
 function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
-  elementToConfig.set(key, properties);
-  observe(key, element, properties);
+  if (properties.disabled) {
+    const config = elementToConfig.get(key);
+    const { observer } = config || {};
+    observer?.unobserve(element);
+    elementToConfig.delete(key);
+  } else {
+    elementToConfig.set(key, properties);
+    observe(key, element, properties);
+  }
 }
 
 function _intersectionObserver(
diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts
index 9f3adc44b0..4fa35c7d93 100644
--- a/web/src/lib/actions/resize-observer.ts
+++ b/web/src/lib/actions/resize-observer.ts
@@ -1,4 +1,4 @@
-type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
+export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
 
 let observer: ResizeObserver;
 let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte
index 8b5b2bff8b..c176402576 100644
--- a/web/src/lib/components/album-page/album-viewer.svelte
+++ b/web/src/lib/components/album-page/album-viewer.svelte
@@ -33,7 +33,10 @@
 
   let { isViewing: showAssetViewer } = assetViewingStore;
 
-  const assetStore = new AssetStore({ albumId: album.id, order: album.order });
+  const assetStore = new AssetStore();
+  $effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order }));
+  onDestroy(() => assetStore.destroy());
+
   const assetInteraction = new AssetInteraction();
 
   dragAndDropFilesStore.subscribe((value) => {
@@ -42,9 +45,6 @@
       dragAndDropFilesStore.set({ isDragging: false, files: [] });
     }
   });
-  onDestroy(() => {
-    assetStore.destroy();
-  });
 </script>
 
 <svelte:window
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 14a273f1c8..dec96f92f8 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -64,7 +64,7 @@
     onClose: (dto: { asset: AssetResponseDto }) => void;
     onNext: () => Promise<HasAsset>;
     onPrevious: () => Promise<HasAsset>;
-    onRandom: () => Promise<AssetResponseDto | null>;
+    onRandom: () => Promise<AssetResponseDto | undefined>;
     copyImage?: () => Promise<void>;
   }
 
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
index 9d69bdeeb2..119efe71b5 100644
--- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
@@ -4,7 +4,6 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { TUNABLES } from '$lib/utils/tunables';
   import { mdiEyeOffOutline } from '@mdi/js';
-  import { onMount } from 'svelte';
   import { fade } from 'svelte/transition';
 
   interface Props {
@@ -37,7 +36,6 @@
     circle = false,
     hidden = false,
     border = false,
-    preload = true,
     hiddenIconClass = 'text-white',
     onComplete = undefined,
   }: Props = $props();
@@ -49,8 +47,6 @@
   let loaded = $state(false);
   let errored = $state(false);
 
-  let img = $state<HTMLImageElement>();
-
   const setLoaded = () => {
     loaded = true;
     onComplete?.();
@@ -59,11 +55,13 @@
     errored = true;
     onComplete?.();
   };
-  onMount(() => {
-    if (img?.complete) {
-      setLoaded();
+
+  function mount(elem: HTMLImageElement) {
+    if (elem.complete) {
+      loaded = true;
+      onComplete?.();
     }
-  });
+  }
 
   let optionalClasses = $derived(
     [
@@ -82,10 +80,9 @@
   <BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
 {:else}
   <img
-    bind:this={img}
+    use:mount
     onload={setLoaded}
     onerror={setErrored}
-    loading={preload ? 'eager' : 'lazy'}
     style:width={widthStyle}
     style:height={heightStyle}
     style:filter={hidden ? 'grayscale(50%)' : 'none'}
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index 3f867cce01..535f8e1408 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { intersectionObserver } from '$lib/actions/intersection-observer';
   import Icon from '$lib/components/elements/icon.svelte';
   import { ProjectionType } from '$lib/constants';
   import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
@@ -22,19 +21,11 @@
   import ImageThumbnail from './image-thumbnail.svelte';
   import VideoThumbnail from './video-thumbnail.svelte';
   import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
-  import { AssetStore } from '$lib/stores/assets-store.svelte';
-
-  import type { DateGroup } from '$lib/utils/timeline-util';
-
-  import { generateId } from '$lib/utils/generate-id';
-  import { onDestroy } from 'svelte';
   import { TUNABLES } from '$lib/utils/tunables';
   import { thumbhash } from '$lib/actions/thumbhash';
 
   interface Props {
     asset: AssetResponseDto;
-    dateGroup?: DateGroup | undefined;
-    assetStore?: AssetStore | undefined;
     groupIndex?: number;
     thumbnailSize?: number | undefined;
     thumbnailWidth?: number | undefined;
@@ -47,29 +38,16 @@
     showArchiveIcon?: boolean;
     showStackedIcon?: boolean;
     disableMouseOver?: boolean;
-    intersectionConfig?: {
-      root?: HTMLElement;
-      bottom?: string;
-      top?: string;
-      left?: string;
-      priority?: number;
-      disabled?: boolean;
-    };
-    retrieveElement?: boolean;
-    onIntersected?: (() => void) | undefined;
+
     onClick?: ((asset: AssetResponseDto) => void) | undefined;
-    onRetrieveElement?: ((elment: HTMLElement) => void) | undefined;
     onSelect?: ((asset: AssetResponseDto) => void) | undefined;
     onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
     handleFocus?: (() => void) | undefined;
     class?: string;
-    overrideDisplayForTest?: boolean;
   }
 
   let {
-    asset,
-    dateGroup = undefined,
-    assetStore = undefined,
+    asset = $bindable(),
     groupIndex = 0,
     thumbnailSize = undefined,
     thumbnailWidth = undefined,
@@ -82,42 +60,21 @@
     showArchiveIcon = false,
     showStackedIcon = true,
     disableMouseOver = false,
-    intersectionConfig = {},
-    retrieveElement = false,
-    onIntersected = undefined,
     onClick = undefined,
-    onRetrieveElement = undefined,
     onSelect = undefined,
     onMouseEvent = undefined,
     handleFocus = undefined,
     class: className = '',
-    overrideDisplayForTest = false,
   }: Props = $props();
 
   let {
     IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
   } = TUNABLES;
 
-  const componentId = generateId();
-  let element: HTMLElement | undefined = $state();
   let focussableElement: HTMLElement | undefined = $state();
   let mouseOver = $state(false);
-  let intersecting = $state(false);
-  let lastRetrievedElement: HTMLElement | undefined = $state();
   let loaded = $state(false);
 
-  $effect(() => {
-    if (!retrieveElement) {
-      lastRetrievedElement = undefined;
-    }
-  });
-  $effect(() => {
-    if (retrieveElement && element && lastRetrievedElement !== element) {
-      lastRetrievedElement = element;
-      onRetrieveElement?.(element);
-    }
-  });
-
   $effect(() => {
     if (focussed && document.activeElement !== focussableElement) {
       focussableElement?.focus();
@@ -126,13 +83,12 @@
 
   let width = $derived(thumbnailSize || thumbnailWidth || 235);
   let height = $derived(thumbnailSize || thumbnailHeight || 235);
-  let display = $derived(intersecting);
 
   const onIconClickedHandler = (e?: MouseEvent) => {
     e?.stopPropagation();
     e?.preventDefault();
     if (!disabled) {
-      onSelect?.(asset);
+      onSelect?.($state.snapshot(asset));
     }
   };
 
@@ -141,7 +97,7 @@
       onIconClickedHandler();
       return;
     }
-    onClick?.(asset);
+    onClick?.($state.snapshot(asset));
   };
   const handleClick = (e: MouseEvent) => {
     if (e.ctrlKey || e.metaKey) {
@@ -152,68 +108,18 @@
     callClickHandlers();
   };
 
-  const _onMouseEnter = () => {
+  const onMouseEnter = () => {
     mouseOver = true;
     onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
   };
 
-  const onMouseEnter = () => {
-    if (dateGroup && assetStore) {
-      assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
-    } else {
-      _onMouseEnter();
-    }
-  };
-
   const onMouseLeave = () => {
-    if (dateGroup && assetStore) {
-      assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
-    } else {
-      mouseOver = false;
-    }
+    mouseOver = false;
   };
-
-  const _onIntersect = () => {
-    intersecting = true;
-    onIntersected?.();
-  };
-
-  const onIntersect = () => {
-    if (intersecting === true) {
-      return;
-    }
-    if (dateGroup && assetStore) {
-      assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect());
-    } else {
-      void _onIntersect();
-    }
-  };
-
-  const onSeparate = () => {
-    if (intersecting === false) {
-      return;
-    }
-    if (dateGroup && assetStore) {
-      assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
-    } else {
-      intersecting = false;
-    }
-  };
-
-  onDestroy(() => {
-    assetStore?.taskManager.removeAllTasksForComponent(componentId);
-  });
 </script>
 
 <div
-  bind:this={element}
-  use:intersectionObserver={{
-    ...intersectionConfig,
-    onIntersect,
-    onSeparate,
-  }}
   data-asset={asset.id}
-  data-int={intersecting}
   style:width="{width}px"
   style:height="{height}px"
   class="focus-visible:outline-none flex overflow-hidden {disabled
@@ -230,166 +136,164 @@
     ></canvas>
   {/if}
 
-  {#if display || overrideDisplayForTest}
-    <!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
+  <!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
      the navigation url on scroll. Replace this with button for now. -->
-    <div
-      class="group"
-      class:cursor-not-allowed={disabled}
-      class:cursor-pointer={!disabled}
-      onmouseenter={onMouseEnter}
-      onmouseleave={onMouseLeave}
-      onkeydown={(evt) => {
-        if (evt.key === 'Enter') {
-          callClickHandlers();
-        }
-        if (evt.key === 'x') {
-          onSelect?.(asset);
-        }
-      }}
-      tabindex={0}
-      onclick={handleClick}
-      role="link"
-      bind:this={focussableElement}
-      onfocus={handleFocus}
-      data-testid="container-with-tabindex"
-    >
-      {#if mouseOver && !disableMouseOver}
-        <!-- lazy show the url on mouse over-->
-        <a
-          class="absolute z-30 {className} top-[41px]"
-          style:cursor="unset"
-          style:width="{width}px"
-          style:height="{height}px"
-          href={currentUrlReplaceAssetId(asset.id)}
-          onclick={(evt) => evt.preventDefault()}
-          tabindex={-1}
-          aria-label="Thumbnail URL"
-        >
-        </a>
-      {/if}
-      <div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
-        <!-- Select asset button  -->
-        {#if !readonly && (mouseOver || selected || selectionCandidate)}
-          <button
-            type="button"
-            onclick={onIconClickedHandler}
-            class="absolute p-2 focus:outline-none"
-            class:cursor-not-allowed={disabled}
-            role="checkbox"
-            tabindex={-1}
-            onfocus={handleFocus}
-            aria-checked={selected}
-            {disabled}
-          >
-            {#if disabled}
-              <Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
-            {:else if selected}
-              <div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
-                <Icon path={mdiCheckCircle} size="24" class="text-immich-primary" />
-              </div>
-            {:else}
-              <Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
-            {/if}
-          </button>
-        {/if}
-      </div>
-
-      <div
-        class="absolute h-full w-full select-none bg-transparent transition-transform"
-        class:scale-[0.85]={selected}
-        class:rounded-xl={selected}
+  <div
+    class="group"
+    style:width="{width}px"
+    style:height="{height}px"
+    class:cursor-not-allowed={disabled}
+    class:cursor-pointer={!disabled}
+    onmouseenter={onMouseEnter}
+    onmouseleave={onMouseLeave}
+    onkeydown={(evt) => {
+      if (evt.key === 'Enter') {
+        callClickHandlers();
+      }
+      if (evt.key === 'x') {
+        onSelect?.(asset);
+      }
+    }}
+    tabindex={0}
+    onclick={handleClick}
+    role="link"
+    bind:this={focussableElement}
+    onfocus={handleFocus}
+    data-testid="container-with-tabindex"
+  >
+    {#if mouseOver && !disableMouseOver}
+      <!-- lazy show the url on mouse over-->
+      <a
+        class="absolute z-30 {className} top-[41px]"
+        style:cursor="unset"
+        style:width="{width}px"
+        style:height="{height}px"
+        href={currentUrlReplaceAssetId(asset.id)}
+        onclick={(evt) => evt.preventDefault()}
+        tabindex={-1}
+        aria-label="Thumbnail URL"
       >
-        <!-- Gradient overlay on hover -->
-        <div
-          class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100"
-          class:rounded-xl={selected}
-        ></div>
-
-        <!-- Outline on focus -->
-        <div
-          class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary"
-        ></div>
-
-        <!-- Favorite asset star -->
-        {#if !isSharedLink() && asset.isFavorite}
-          <div class="absolute bottom-2 left-2 z-10">
-            <Icon path={mdiHeart} size="24" class="text-white" />
-          </div>
-        {/if}
-
-        {#if !isSharedLink() && showArchiveIcon && asset.isArchived}
-          <div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10">
-            <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
-          </div>
-        {/if}
-
-        {#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
-          <div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
-            <span class="pr-2 pt-2">
-              <Icon path={mdiRotate360} size="24" />
-            </span>
-          </div>
-        {/if}
-
-        <!-- Stacked asset -->
-
-        {#if asset.stack && showStackedIcon}
-          <div
-            class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined
-              ? 'top-0 right-0'
-              : 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white"
-          >
-            <span class="pr-2 pt-2 flex place-items-center gap-1">
-              <p>{asset.stack.assetCount.toLocaleString($locale)}</p>
-              <Icon path={mdiCameraBurst} size="24" />
-            </span>
-          </div>
-        {/if}
-
-        <ImageThumbnail
-          url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
-          altText={$getAltText(asset)}
-          widthStyle="{width}px"
-          heightStyle="{height}px"
-          curve={selected}
-          onComplete={() => (loaded = true)}
-        />
-
-        {#if asset.type === AssetTypeEnum.Video}
-          <div class="absolute top-0 h-full w-full">
-            <VideoThumbnail
-              {assetStore}
-              url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
-              enablePlayback={mouseOver && $playVideoThumbnailOnHover}
-              curve={selected}
-              durationInSeconds={timeToSeconds(asset.duration)}
-              playbackOnIconHover={!$playVideoThumbnailOnHover}
-            />
-          </div>
-        {/if}
-
-        {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
-          <div class="absolute top-0 h-full w-full">
-            <VideoThumbnail
-              {assetStore}
-              url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
-              pauseIcon={mdiMotionPauseOutline}
-              playIcon={mdiMotionPlayOutline}
-              showTime={false}
-              curve={selected}
-              playbackOnIconHover
-            />
-          </div>
-        {/if}
-      </div>
-      {#if selectionCandidate}
-        <div
-          class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
-          in:fade={{ duration: 100 }}
-          out:fade={{ duration: 100 }}
-        ></div>
+      </a>
+    {/if}
+    <div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
+      <!-- Select asset button  -->
+      {#if !readonly && (mouseOver || selected || selectionCandidate)}
+        <button
+          type="button"
+          onclick={onIconClickedHandler}
+          class="absolute p-2 focus:outline-none"
+          class:cursor-not-allowed={disabled}
+          role="checkbox"
+          tabindex={-1}
+          onfocus={handleFocus}
+          aria-checked={selected}
+          {disabled}
+        >
+          {#if disabled}
+            <Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
+          {:else if selected}
+            <div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
+              <Icon path={mdiCheckCircle} size="24" class="text-immich-primary" />
+            </div>
+          {:else}
+            <Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
+          {/if}
+        </button>
       {/if}
     </div>
-  {/if}
+
+    <div
+      class="absolute h-full w-full select-none bg-transparent transition-transform"
+      class:scale-[0.85]={selected}
+      class:rounded-xl={selected}
+    >
+      <!-- Gradient overlay on hover -->
+      <div
+        class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100"
+        class:rounded-xl={selected}
+      ></div>
+
+      <!-- Outline on focus -->
+      <div
+        class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary"
+      ></div>
+
+      <!-- Favorite asset star -->
+      {#if !isSharedLink() && asset.isFavorite}
+        <div class="absolute bottom-2 left-2 z-10">
+          <Icon path={mdiHeart} size="24" class="text-white" />
+        </div>
+      {/if}
+
+      {#if !isSharedLink() && showArchiveIcon && asset.isArchived}
+        <div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10">
+          <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
+        </div>
+      {/if}
+
+      {#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
+        <div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
+          <span class="pr-2 pt-2">
+            <Icon path={mdiRotate360} size="24" />
+          </span>
+        </div>
+      {/if}
+
+      <!-- Stacked asset -->
+
+      {#if asset.stack && showStackedIcon}
+        <div
+          class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined
+            ? 'top-0 right-0'
+            : 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white"
+        >
+          <span class="pr-2 pt-2 flex place-items-center gap-1">
+            <p>{asset.stack.assetCount.toLocaleString($locale)}</p>
+            <Icon path={mdiCameraBurst} size="24" />
+          </span>
+        </div>
+      {/if}
+
+      <ImageThumbnail
+        url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
+        altText={$getAltText(asset)}
+        widthStyle="{width}px"
+        heightStyle="{height}px"
+        curve={selected}
+        onComplete={() => (loaded = true)}
+      />
+
+      {#if asset.type === AssetTypeEnum.Video}
+        <div class="absolute top-0 h-full w-full">
+          <VideoThumbnail
+            url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
+            enablePlayback={mouseOver && $playVideoThumbnailOnHover}
+            curve={selected}
+            durationInSeconds={timeToSeconds(asset.duration)}
+            playbackOnIconHover={!$playVideoThumbnailOnHover}
+          />
+        </div>
+      {/if}
+
+      {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
+        <div class="absolute top-0 h-full w-full">
+          <VideoThumbnail
+            url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
+            pauseIcon={mdiMotionPauseOutline}
+            playIcon={mdiMotionPlayOutline}
+            showTime={false}
+            curve={selected}
+            playbackOnIconHover
+          />
+        </div>
+      {/if}
+    </div>
+    {#if selectionCandidate}
+      <div
+        class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
+        in:fade={{ duration: 100 }}
+        out:fade={{ duration: 100 }}
+      ></div>
+    {/if}
+  </div>
 </div>
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
index a2e30be543..fc3cb2e951 100644
--- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
@@ -3,12 +3,8 @@
   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
   import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
-  import { AssetStore } from '$lib/stores/assets-store.svelte';
-  import { generateId } from '$lib/utils/generate-id';
-  import { onDestroy } from 'svelte';
 
   interface Props {
-    assetStore?: AssetStore | undefined;
     url: string;
     durationInSeconds?: number;
     enablePlayback?: boolean;
@@ -20,7 +16,6 @@
   }
 
   let {
-    assetStore = undefined,
     url,
     durationInSeconds = 0,
     enablePlayback = $bindable(false),
@@ -31,7 +26,6 @@
     pauseIcon = mdiPauseCircleOutline,
   }: Props = $props();
 
-  const componentId = generateId();
   let remainingSeconds = $state(durationInSeconds);
   let loading = $state(true);
   let error = $state(false);
@@ -49,42 +43,16 @@
     }
   });
   const onMouseEnter = () => {
-    if (assetStore) {
-      assetStore.taskManager.queueScrollSensitiveTask({
-        componentId,
-        task: () => {
-          if (playbackOnIconHover) {
-            enablePlayback = true;
-          }
-        },
-      });
-    } else {
-      if (playbackOnIconHover) {
-        enablePlayback = true;
-      }
+    if (playbackOnIconHover) {
+      enablePlayback = true;
     }
   };
 
   const onMouseLeave = () => {
-    if (assetStore) {
-      assetStore.taskManager.queueScrollSensitiveTask({
-        componentId,
-        task: () => {
-          if (playbackOnIconHover) {
-            enablePlayback = false;
-          }
-        },
-      });
-    } else {
-      if (playbackOnIconHover) {
-        enablePlayback = false;
-      }
+    if (playbackOnIconHover) {
+      enablePlayback = false;
     }
   };
-
-  onDestroy(() => {
-    assetStore?.taskManager.removeAllTasksForComponent(componentId);
-  });
 </script>
 
 <div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte
index 4cc43ef199..e993d3694d 100644
--- a/web/src/lib/components/photos-page/asset-date-group.svelte
+++ b/web/src/lib/components/photos-page/asset-date-group.svelte
@@ -1,56 +1,51 @@
 <script lang="ts">
-  import { intersectionObserver } from '$lib/actions/intersection-observer';
   import Icon from '$lib/components/elements/icon.svelte';
-  import Skeleton from '$lib/components/photos-page/skeleton.svelte';
-  import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte';
+  import { AssetBucket } from '$lib/stores/assets-store.svelte';
   import { navigate } from '$lib/utils/navigation';
-  import {
-    findTotalOffset,
-    type DateGroup,
-    type ScrollTargetListener,
-    getDateLocaleString,
-  } from '$lib/utils/timeline-util';
+  import { getDateLocaleString } from '$lib/utils/timeline-util';
   import type { AssetResponseDto } from '@immich/sdk';
   import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
-  import { onDestroy } from 'svelte';
   import { fly } from 'svelte/transition';
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
-  import { TUNABLES } from '$lib/utils/tunables';
-  import { generateId } from '$lib/utils/generate-id';
   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
+  import { scale } from 'svelte/transition';
 
-  export let element: HTMLElement | undefined = undefined;
-  export let isSelectionMode = false;
-  export let viewport: Viewport;
-  export let singleSelect = false;
-  export let withStacked = false;
-  export let showArchiveIcon = false;
-  export let assetGridElement: HTMLElement | undefined = undefined;
-  export let renderThumbsAtBottomMargin: string | undefined = undefined;
-  export let renderThumbsAtTopMargin: string | undefined = undefined;
-  export let assetStore: AssetStore;
-  export let bucket: AssetBucket;
-  export let assetInteraction: AssetInteraction;
+  import { flip } from 'svelte/animate';
 
-  export let onScrollTarget: ScrollTargetListener | undefined = undefined;
-  export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
-  export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
-  export let onSelectAssets: (asset: AssetResponseDto) => void;
-  export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
+  import { uploadAssetsStore } from '$lib/stores/upload';
 
-  const componentId = generateId();
-  $: bucketDate = bucket.bucketDate;
-  $: dateGroups = bucket.dateGroups;
+  let { isUploading } = uploadAssetsStore;
 
-  const {
-    DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
-  } = TUNABLES;
-  /* TODO figure out a way to calculate this*/
-  const TITLE_HEIGHT = 51;
+  interface Props {
+    isSelectionMode: boolean;
+    singleSelect: boolean;
+    withStacked: boolean;
+    showArchiveIcon: boolean;
+    bucket: AssetBucket;
+    assetInteraction: AssetInteraction;
 
-  let isMouseOverGroup = false;
-  let hoveredDateGroup = '';
+    onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
+    onSelectAssets: (asset: AssetResponseDto) => void;
+    onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
+  }
 
+  let {
+    isSelectionMode,
+    singleSelect,
+    withStacked,
+    showArchiveIcon,
+    bucket = $bindable(),
+    assetInteraction,
+    onSelect,
+    onSelectAssets,
+    onSelectAssetCandidates,
+  }: Props = $props();
+
+  let isMouseOverGroup = $state(false);
+  let hoveredDateGroup = $state();
+
+  const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
+  const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
   const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
     if (isSelectionMode || assetInteraction.selectionActive) {
       assetSelectHandler(asset, assets, groupTitle);
@@ -59,13 +54,6 @@
     void navigate({ targetRoute: 'current', assetId: asset.id });
   };
 
-  const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
-    if (assetGridElement && onScrollTarget) {
-      const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
-      onScrollTarget({ bucket, dateGroup, asset, offset });
-    }
-  };
-
   const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
 
   const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
@@ -73,7 +61,7 @@
 
     // Check if all assets are selected in a group to toggle the group selection's icon
     let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) =>
-      assetInteraction.selectedAssets.has(asset),
+      assetInteraction.hasSelectedAsset(asset.id),
     ).length;
 
     // if all assets are selected in a group, add the group to selected group
@@ -83,7 +71,9 @@
       assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
     }
   };
-
+  const snapshotAssetArray = (assets: AssetResponseDto[]) => {
+    return assets.map((a) => $state.snapshot(a));
+  };
   const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
     // Show multi select icon on hover on date group
     hoveredDateGroup = groupTitle;
@@ -96,155 +86,100 @@
   const assetOnFocusHandler = (asset: AssetResponseDto) => {
     assetInteraction.focussedAssetId = asset.id;
   };
-
-  onDestroy(() => {
-    assetStore.taskManager.removeAllTasksForComponent(componentId);
-  });
+  function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
+    return intersectable.filter((int) => int.intersecting);
+  }
 </script>
 
-<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
-  {#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
-    {@const display =
-      dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)}
-    {@const geometry = dateGroup.geometry!}
+{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
+  {@const absoluteWidth = dateGroup.left}
 
+  <!-- svelte-ignore a11y_no_static_element_interactions -->
+  <section
+    class={[
+      { 'transition-all': !bucket.store.suspendTransitions },
+      !bucket.store.suspendTransitions && `delay-${transitionDuration}`,
+    ]}
+    data-group
+    style:position="absolute"
+    style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`}
+    onmouseenter={() => {
+      isMouseOverGroup = true;
+      assetMouseEventHandler(dateGroup.groupTitle, null);
+    }}
+    onmouseleave={() => {
+      isMouseOverGroup = false;
+      assetMouseEventHandler(dateGroup.groupTitle, null);
+    }}
+  >
+    <!-- Date group title -->
     <div
-      id="date-group"
-      use:intersectionObserver={{
-        onIntersect: () => {
-          assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
-            assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
-          );
-        },
-        onSeparate: () => {
-          assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
-            assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
-          );
-        },
-        top: INTERSECTION_ROOT_TOP,
-        bottom: INTERSECTION_ROOT_BOTTOM,
-        root: assetGridElement,
-      }}
-      data-display={display}
-      data-date-group={dateGroup.date}
-      style:height={dateGroup.height + 'px'}
-      style:width={geometry.containerWidth + 'px'}
-      style:overflow="clip"
+      class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
+      style:width={dateGroup.width + 'px'}
     >
-      {#if !display}
-        <Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
-      {/if}
-      {#if display}
-        <!-- Asset Group By Date -->
-        <!-- svelte-ignore a11y-no-static-element-interactions -->
+      {#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
         <div
-          on:mouseenter={() =>
-            assetStore.taskManager.queueScrollSensitiveTask({
-              componentId,
-              task: () => {
-                isMouseOverGroup = true;
-                assetMouseEventHandler(dateGroup.groupTitle, null);
-              },
-            })}
-          on:mouseleave={() => {
-            assetStore.taskManager.queueScrollSensitiveTask({
-              componentId,
-              task: () => {
-                isMouseOverGroup = false;
-                assetMouseEventHandler(dateGroup.groupTitle, null);
-              },
-            });
-          }}
+          transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
+          class="inline-block px-2 hover:cursor-pointer"
+          onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
+          onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
         >
-          <!-- Date group title -->
-          <div
-            class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
-            style:width={geometry.containerWidth + 'px'}
-          >
-            {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
-              <div
-                transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
-                class="inline-block px-2 hover:cursor-pointer"
-                on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
-                on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
-              >
-                {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
-                  <Icon path={mdiCheckCircle} size="24" color="#4250af" />
-                {:else}
-                  <Icon path={mdiCircleOutline} size="24" color="#757575" />
-                {/if}
-              </div>
-            {/if}
-
-            <span class="w-full truncate first-letter:capitalize" title={getDateLocaleString(dateGroup.date)}>
-              {dateGroup.groupTitle}
-            </span>
-          </div>
-
-          <!-- Image grid -->
-          <div
-            class="relative overflow-clip"
-            style:height={geometry.containerHeight + 'px'}
-            style:width={geometry.containerWidth + 'px'}
-          >
-            {#each dateGroup.assets as asset, i (asset.id)}
-              <!-- getting these together here in this order is very cache-efficient -->
-              {@const top = geometry.getTop(i)}
-              {@const left = geometry.getLeft(i)}
-              {@const width = geometry.getWidth(i)}
-              {@const height = geometry.getHeight(i)}
-              <!-- update ASSET_GRID_PADDING-->
-              <div
-                use:intersectionObserver={{
-                  onIntersect: () => onAssetInGrid?.(asset),
-                  top: `${-TITLE_HEIGHT}px`,
-                  bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
-                  right: `${-(viewport.width - 1)}px`,
-                  root: assetGridElement,
-                }}
-                data-asset-id={asset.id}
-                class="absolute"
-                style:top={top + 'px'}
-                style:left={left + 'px'}
-                style:width={width + 'px'}
-                style:height={height + 'px'}
-              >
-                <Thumbnail
-                  {dateGroup}
-                  {assetStore}
-                  intersectionConfig={{
-                    root: assetGridElement,
-                    bottom: renderThumbsAtBottomMargin,
-                    top: renderThumbsAtTopMargin,
-                  }}
-                  retrieveElement={assetStore.pendingScrollAssetId === asset.id}
-                  onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
-                  showStackedIcon={withStacked}
-                  {showArchiveIcon}
-                  {asset}
-                  {groupIndex}
-                  onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
-                  onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
-                  onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
-                  selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)}
-                  handleFocus={() => assetOnFocusHandler(asset)}
-                  focussed={assetInteraction.isFocussedAsset(asset)}
-                  selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
-                  disabled={assetStore.albumAssets.has(asset.id)}
-                  thumbnailWidth={width}
-                  thumbnailHeight={height}
-                />
-              </div>
-            {/each}
-          </div>
+          {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
+            <Icon path={mdiCheckCircle} size="24" color="#4250af" />
+          {:else}
+            <Icon path={mdiCircleOutline} size="24" color="#757575" />
+          {/if}
         </div>
       {/if}
+
+      <span class="w-full truncate first-letter:capitalize" title={getDateLocaleString(dateGroup.date)}>
+        {dateGroup.groupTitle}
+      </span>
     </div>
-  {/each}
-</section>
+
+    <!-- Image grid -->
+    <div class="relative overflow-clip" style:height={dateGroup.height + 'px'} style:width={dateGroup.width + 'px'}>
+      {#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)}
+        {@const position = intersectingAsset.position!}
+        {@const asset = intersectingAsset.asset!}
+
+        <!-- {#if intersectingAsset.intersecting} -->
+        <!-- note: don't remove data-asset-id - its used by web e2e tests -->
+        <div
+          data-asset-id={asset.id}
+          class="absolute"
+          style:top={position.top + 'px'}
+          style:left={position.left + 'px'}
+          style:width={position.width + 'px'}
+          style:height={position.height + 'px'}
+          out:scale|global={{ start: 0.1, duration: scaleDuration }}
+          animate:flip={{ duration: transitionDuration }}
+        >
+          <Thumbnail
+            showStackedIcon={withStacked}
+            {showArchiveIcon}
+            {asset}
+            {groupIndex}
+            focussed={assetInteraction.isFocussedAsset(asset)}
+            onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)}
+            onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)}
+            onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))}
+            selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
+            selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
+            handleFocus={() => assetOnFocusHandler(asset)}
+            disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
+            thumbnailWidth={position.width}
+            thumbnailHeight={position.height}
+          />
+        </div>
+        <!-- {/if} -->
+      {/each}
+    </div>
+  </section>
+{/each}
 
 <style>
-  #asset-group-by-date {
+  section {
     contain: layout paint style;
   }
 </style>
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index 1f4f9aca85..970f09793f 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -4,38 +4,26 @@
   import type { Action } from '$lib/components/asset-viewer/actions/action';
   import { AppRoute, AssetAction } from '$lib/constants';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
-  import { locale, showDeleteModal } from '$lib/stores/preferences.store';
+  import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
+  import { showDeleteModal } from '$lib/stores/preferences.store';
   import { isSearchEnabled } from '$lib/stores/search.store';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { handlePromiseError } from '$lib/utils';
   import { deleteAssets } from '$lib/utils/actions';
   import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
   import { navigate } from '$lib/utils/navigation';
-  import {
-    formatGroupTitle,
-    splitBucketIntoDateGroups,
-    type ScrubberListener,
-    type ScrollTargetListener,
-  } from '$lib/utils/timeline-util';
-  import { TUNABLES } from '$lib/utils/tunables';
+  import { type ScrubberListener } from '$lib/utils/timeline-util';
   import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
-  import { throttle } from 'lodash-es';
-  import { onDestroy, onMount, type Snippet } from 'svelte';
+  import { onMount, type Snippet } from 'svelte';
   import Portal from '../shared-components/portal/portal.svelte';
   import Scrubber from '../shared-components/scrubber/scrubber.svelte';
   import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
   import AssetDateGroup from './asset-date-group.svelte';
   import DeleteAssetDialog from './delete-asset-dialog.svelte';
-
-  import { resizeObserver } from '$lib/actions/resize-observer';
-  import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte';
-  import { intersectionObserver } from '$lib/actions/intersection-observer';
+  import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
   import Skeleton from '$lib/components/photos-page/skeleton.svelte';
   import { page } from '$app/stores';
   import type { UpdatePayload } from 'vite';
-  import { generateId } from '$lib/utils/generate-id';
-  import { isTimelineScrolling } from '$lib/stores/timeline.store';
   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
 
   interface Props {
@@ -81,64 +69,41 @@
 
   let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
 
-  const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
-  const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
-
-  const componentId = generateId();
   let element: HTMLElement | undefined = $state();
+
   let timelineElement: HTMLElement | undefined = $state();
   let showShortcuts = $state(false);
   let showSkeleton = $state(true);
-  let internalScroll = false;
-  let navigating = false;
-  let preMeasure: AssetBucket[] = $state([]);
-  let lastIntersectedBucketDate: string | undefined;
   let scrubBucketPercent = $state(0);
   let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
   let scrubOverallPercent: number = $state(0);
-  let topSectionHeight = $state(0);
-  let topSectionOffset = $state(0);
+
   // 60 is the bottom spacer element at 60px
   let bottomSectionHeight = 60;
   let leadout = $state(false);
 
-  const {
-    ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
-    BUCKET: {
-      INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
-      INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
-    },
-    THUMBNAIL: {
-      INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP,
-      INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM,
-    },
-  } = TUNABLES;
-
-  const isViewportOrigin = () => {
-    return viewport.height === 0 && viewport.width === 0;
-  };
-  const isEqual = (a: ViewportXY, b: ViewportXY) => {
-    return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
-  };
-
-  const completeNav = () => {
-    navigating = false;
-    if (internalScroll) {
-      internalScroll = false;
-      return;
-    }
-
+  const completeNav = async () => {
     if ($gridScrollTarget?.at) {
-      void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
+      try {
+        const bucket = await assetStore.findBucketForAsset($gridScrollTarget.at);
+        if (bucket) {
+          const height = bucket.findAssetAbsolutePosition($gridScrollTarget.at);
+          if (height) {
+            element?.scrollTo({ top: height });
+            showSkeleton = false;
+            assetStore.updateIntersections();
+          }
+        }
+      } catch {
         element?.scrollTo({ top: 0 });
         showSkeleton = false;
-      });
+      }
     } else {
       element?.scrollTo({ top: 0 });
       showSkeleton = false;
     }
   };
-
+  beforeNavigate(() => (assetStore.suspendTransitions = true));
   afterNavigate((nav) => {
     const { complete, type } = nav;
     if (type === 'enter') {
@@ -147,10 +112,6 @@
     complete.then(completeNav, completeNav);
   });
 
-  beforeNavigate(() => {
-    navigating = true;
-  });
-
   const hmrSupport = () => {
     // when hmr happens, skeleton is initialized to true by default
     // normally, loading asset-grid is part of a navigation event, and the completion of
@@ -165,7 +126,6 @@
 
         if (assetGridUpdate) {
           setTimeout(() => {
-            void assetStore.updateViewport(safeViewport, true);
             const asset = $page.url.searchParams.get('at');
             if (asset) {
               $gridScrollTarget = { at: asset };
@@ -193,94 +153,60 @@
     return () => void 0;
   };
 
-  const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
-    if (lastIntersectedBucketDate) {
-      const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
-      const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
-
-      if (deltaIndex < currentIndex) {
-        element?.scrollBy(0, delta);
-      }
-    }
-  };
-
-  const bucketListener: BucketListener = (event) => {
-    const { type } = event;
-    if (type === 'bucket-height') {
-      const { bucket, delta } = event;
-      scrollTolastIntersectedBucket(bucket, delta);
-    }
-  };
+  const updateIsScrolling = () => (assetStore.scrolling = true);
+  // note: don't throttle, debounch, or otherwise do this function async - it causes flicker
+  const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
+  const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
+  const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
 
   onMount(() => {
-    void assetStore
-      .init({ bucketListener })
-      .then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
+    assetStore.setCompensateScrollCallback(compensateScrollCallback);
     if (!enableRouting) {
       showSkeleton = false;
     }
-    const dispose = hmrSupport();
+    const disposeHmr = hmrSupport();
     return () => {
-      assetStore.disconnect();
-      assetStore.destroy();
-      dispose();
+      assetStore.setCompensateScrollCallback();
+      disposeHmr();
     };
   });
 
-  const _updateViewport = () => void assetStore.updateViewport(safeViewport);
-  const updateViewport = throttle(_updateViewport, 16);
-
-  function getOffset(bucketDate: string) {
-    let offset = 0;
-    for (let a = 0; a < assetStore.buckets.length; a++) {
-      if (assetStore.buckets[a].bucketDate === bucketDate) {
-        break;
-      }
-      offset += assetStore.buckets[a].bucketHeight;
-    }
-    return offset;
-  }
-
-  const getMaxScrollPercent = () =>
-    (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
-    (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
+  const getMaxScrollPercent = () => {
+    const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
+    return (totalHeight - assetStore.viewportHeight) / totalHeight;
+  };
 
   const getMaxScroll = () => {
     if (!element || !timelineElement) {
       return 0;
     }
-
-    return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
+    return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
   };
 
   const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
-    const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
+    const topOffset = bucket.top;
     const maxScrollPercent = getMaxScrollPercent();
     const delta = bucket.bucketHeight * bucketScrollPercent;
     const scrollTop = (topOffset + delta) * maxScrollPercent;
 
-    if (!element) {
-      return;
+    if (element) {
+      element.scrollTop = scrollTop;
     }
-
-    element.scrollTop = scrollTop;
   };
 
-  const _onScrub: ScrubberListener = (
+  // note: don't throttle, debounch, or otherwise make this function async - it causes flicker
+  const onScrub: ScrubberListener = (
     bucketDate: string | undefined,
     scrollPercent: number,
     bucketScrollPercent: number,
   ) => {
-    if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
+    if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 2) {
       // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
-
       const maxScroll = getMaxScroll();
       const offset = maxScroll * scrollPercent;
-
       if (!element) {
         return;
       }
-
       element.scrollTop = offset;
     } else {
       const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
@@ -290,47 +216,16 @@
       scrollToBucketAndOffset(bucket, bucketScrollPercent);
     }
   };
-  const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true });
-
-  const stopScrub: ScrubberListener = async (
-    bucketDate: string | undefined,
-    _scrollPercent: number,
-    bucketScrollPercent: number,
-  ) => {
-    if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
-      // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
-      return;
-    }
-    const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
-    if (!bucket) {
-      return;
-    }
-    if (bucket && !bucket.measured) {
-      preMeasure.push(bucket);
-      await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
-      await bucket.measuredPromise;
-      scrollToBucketAndOffset(bucket, bucketScrollPercent);
-    }
-  };
-
-  let scrollObserverTimer: NodeJS.Timeout;
-
-  const _handleTimelineScroll = () => {
-    $isTimelineScrolling = true;
-    if (scrollObserverTimer) {
-      clearTimeout(scrollObserverTimer);
-    }
-    scrollObserverTimer = setTimeout(() => {
-      $isTimelineScrolling = false;
-    }, 1000);
 
+  // note: don't throttle, debounch, or otherwise make this function async - it causes flicker
+  const handleTimelineScroll = () => {
     leadout = false;
 
     if (!element) {
       return;
     }
 
-    if (assetStore.timelineHeight < safeViewport.height * 2) {
+    if (assetStore.timelineHeight < assetStore.viewportHeight * 2) {
       // edge case - scroll limited due to size of content, must adjust -  use the overall percent instead
       const maxScroll = getMaxScroll();
       scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
@@ -338,8 +233,8 @@
       scrubBucket = undefined;
       scrubBucketPercent = 0;
     } else {
-      let top = element?.scrollTop;
-      if (top < topSectionHeight) {
+      let top = element.scrollTop;
+      if (top < assetStore.topSectionHeight) {
         // in the lead-in area
         scrubBucket = undefined;
         scrubBucketPercent = 0;
@@ -352,18 +247,24 @@
       let maxScrollPercent = getMaxScrollPercent();
       let found = false;
 
-      // create virtual buckets....
-      const vbuckets = [
-        { bucketHeight: topSectionHeight, bucketDate: undefined },
-        ...assetStore.buckets,
-        { bucketHeight: bottomSectionHeight, bucketDate: undefined },
-      ];
-
-      for (const bucket of vbuckets) {
-        let next = top - bucket.bucketHeight * maxScrollPercent;
+      const bucketsLength = assetStore.buckets.length;
+      for (let i = -1; i < bucketsLength + 1; i++) {
+        let bucket: { bucketDate: string | undefined } | undefined;
+        let bucketHeight = 0;
+        if (i === -1) {
+          // lead-in
+          bucketHeight = assetStore.topSectionHeight;
+        } else if (i === bucketsLength) {
+          // lead-out
+          bucketHeight = bottomSectionHeight;
+        } else {
+          bucket = assetStore.buckets[i];
+          bucketHeight = assetStore.buckets[i].bucketHeight;
+        }
+        let next = top - bucketHeight * maxScrollPercent;
         if (next < 0) {
           scrubBucket = bucket;
-          scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
+          scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
           found = true;
           break;
         }
@@ -377,34 +278,6 @@
       }
     }
   };
-  const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true });
-
-  const _onAssetInGrid = async (asset: AssetResponseDto) => {
-    if (!enableRouting || navigating || internalScroll) {
-      return;
-    }
-    $gridScrollTarget = { at: asset.id };
-    internalScroll = true;
-    await navigate(
-      { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
-      { replaceState: true, forceNavigate: true },
-    );
-  };
-  const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW
-    ? throttle(_onAssetInGrid, 16, { leading: false, trailing: true })
-    : () => void 0;
-
-  const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
-    element?.scrollTo({ top: offset });
-    if (!bucket.measured) {
-      preMeasure.push(bucket);
-    }
-    showSkeleton = false;
-    assetStore.clearPendingScroll();
-    // set intersecting true manually here, to reduce flicker that happens when
-    // clearing pending scroll, but the intersection observer hadn't yet had time to run
-    assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
-  };
 
   const trashOrDelete = async (force: boolean = false) => {
     isShowDeleteConfirmation = false;
@@ -439,11 +312,9 @@
   };
 
   const toggleArchive = async () => {
-    const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
-    if (ids) {
-      assetStore.removeAssets(ids);
-      deselectAllAssets();
-    }
+    await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
+    assetStore.updateAssets(assetInteraction.selectedAssetsArray);
+    deselectAllAssets();
   };
 
   const focusElement = () => {
@@ -458,23 +329,6 @@
     }
   };
 
-  function handleIntersect(bucket: AssetBucket) {
-    // updateLastIntersectedBucketDate();
-    const task = () => {
-      assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
-      void assetStore.loadBucket(bucket.bucketDate);
-    };
-    assetStore.taskManager.intersectedBucket(componentId, bucket, task);
-  }
-
-  function handleSeparate(bucket: AssetBucket) {
-    const task = () => {
-      assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
-      bucket.cancel();
-    };
-    assetStore.taskManager.separatedBucket(componentId, bucket, task);
-  }
-
   const handlePrevious = async () => {
     const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
 
@@ -610,7 +464,6 @@
     if (!asset) {
       return;
     }
-
     onSelect(asset);
 
     if (singleSelect && element) {
@@ -619,7 +472,7 @@
     }
 
     const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
-    const deselect = assetInteraction.selectedAssets.has(asset);
+    const deselect = assetInteraction.hasSelectedAsset(asset.id);
 
     // Select/deselect already loaded assets
     if (deselect) {
@@ -637,39 +490,48 @@
     assetInteraction.clearAssetSelectionCandidates();
 
     if (assetInteraction.assetSelectionStart && rangeSelection) {
-      let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
-      let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
+      let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
+      let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
 
-      if (startBucketIndex === null || endBucketIndex === null) {
+      if (startBucket === null || endBucket === null) {
         return;
       }
 
-      if (endBucketIndex < startBucketIndex) {
-        [startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
-      }
-
-      // Select/deselect assets in all intermediate buckets
-      for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
-        const bucket = assetStore.buckets[bucketIndex];
-        await assetStore.loadBucket(bucket.bucketDate);
-        for (const asset of bucket.assets) {
-          if (deselect) {
-            assetInteraction.removeAssetFromMultiselectGroup(asset);
-          } else {
-            handleSelectAsset(asset);
+      // Select/deselect assets in range (start,end]
+      let started = false;
+      for (const bucket of assetStore.buckets) {
+        if (bucket === startBucket) {
+          started = true;
+        }
+        if (bucket === endBucket) {
+          break;
+        }
+        if (started) {
+          await assetStore.loadBucket(bucket.bucketDate);
+          for (const asset of bucket.getAssets()) {
+            if (deselect) {
+              assetInteraction.removeAssetFromMultiselectGroup(asset);
+            } else {
+              handleSelectAsset(asset);
+            }
           }
         }
       }
 
       // Update date group selection
-      for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
-        const bucket = assetStore.buckets[bucketIndex];
+      started = false;
+      for (const bucket of assetStore.buckets) {
+        if (bucket === startBucket) {
+          started = true;
+        }
+        if (bucket === endBucket) {
+          break;
+        }
 
         // Split bucket into date groups and check each group
-        const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
-        for (const dateGroup of assetsGroupByDate) {
-          const dateGroupTitle = formatGroupTitle(dateGroup.date);
-          if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) {
+        for (const dateGroup of bucket.dateGroups) {
+          const dateGroupTitle = dateGroup.groupTitle;
+          if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
             assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
           } else {
             assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
@@ -691,14 +553,16 @@
       return;
     }
 
-    let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
-    let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
+    const assets = assetStore.getAssets();
+
+    let start = assets.findIndex((a) => a.id === startAsset.id);
+    let end = assets.findIndex((a) => a.id === endAsset.id);
 
     if (start > end) {
       [start, end] = [end, start];
     }
 
-    assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
+    assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
   };
 
   const onSelectStart = (e: Event) => {
@@ -710,14 +574,14 @@
   const focusNextAsset = async () => {
     if (assetInteraction.focussedAssetId === null) {
       const firstAsset = assetStore.getFirstAsset();
-      if (firstAsset !== null) {
+      if (firstAsset) {
         assetInteraction.focussedAssetId = firstAsset.id;
       }
     } else {
-      const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
+      const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
       if (focussedAsset) {
         const nextAsset = await assetStore.getNextAsset(focussedAsset);
-        if (nextAsset !== null) {
+        if (nextAsset) {
           assetInteraction.focussedAssetId = nextAsset.id;
         }
       }
@@ -726,7 +590,7 @@
 
   const focusPreviousAsset = async () => {
     if (assetInteraction.focussedAssetId !== null) {
-      const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
+      const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
       if (focussedAsset) {
         const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
         if (previousAsset) {
@@ -736,11 +600,8 @@
     }
   };
 
-  onDestroy(() => {
-    assetStore.taskManager.removeAllTasksForComponent(componentId);
-  });
   let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
-  let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0);
+  let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
   let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
 
   $effect(() => {
@@ -749,23 +610,6 @@
     }
   });
 
-  $effect(() => {
-    if (element && isViewportOrigin()) {
-      const rect = element.getBoundingClientRect();
-      viewport.height = rect.height;
-      viewport.width = rect.width;
-      viewport.x = rect.x;
-      viewport.y = rect.y;
-    }
-    if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
-      safeViewport.height = viewport.height;
-      safeViewport.width = viewport.width;
-      safeViewport.x = viewport.x;
-      safeViewport.y = viewport.y;
-      updateViewport();
-    }
-  });
-
   let shortcutList = $derived(
     (() => {
       if ($isSearchEnabled || $showAssetViewer) {
@@ -829,19 +673,34 @@
 {#if showShortcuts}
   <ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
 {/if}
+
 {#if assetStore.buckets.length > 0}
   <Scrubber
     invisible={showSkeleton}
     {assetStore}
-    height={safeViewport.height}
-    timelineTopOffset={topSectionHeight}
+    height={assetStore.viewportHeight}
+    timelineTopOffset={assetStore.topSectionHeight}
     timelineBottomOffset={bottomSectionHeight}
     {leadout}
     {scrubOverallPercent}
     {scrubBucketPercent}
     {scrubBucket}
     {onScrub}
-    {stopScrub}
+    onScrubKeyDown={(evt) => {
+      evt.preventDefault();
+      let amount = 50;
+      if (shiftKeyIsDown) {
+        amount = 500;
+      }
+      if (evt.key === 'ArrowUp') {
+        amount = -amount;
+        if (shiftKeyIsDown) {
+          element?.scrollBy({ top: amount, behavior: 'smooth' });
+        }
+      } else if (evt.key === 'ArrowDown') {
+        element?.scrollBy({ top: amount, behavior: 'smooth' });
+      }
+    }}
   />
 {/if}
 
@@ -850,90 +709,67 @@
   id="asset-grid"
   class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
   tabindex="-1"
-  use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
+  bind:clientHeight={assetStore.viewportHeight}
+  bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
   bind:this={element}
-  onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
+  onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
 >
-  <section
-    use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
-    class:invisible={showSkeleton}
-  >
-    {@render children?.()}
-    {#if isEmpty}
-      <!-- (optional) empty placeholder -->
-      {@render empty?.()}
-    {/if}
-  </section>
-
   <section
     bind:this={timelineElement}
     id="virtual-timeline"
     class:invisible={showSkeleton}
     style:height={assetStore.timelineHeight + 'px'}
   >
+    <section
+      use:resizeObserver={topSectionResizeObserver}
+      class:invisible={showSkeleton}
+      style:position="absolute"
+      style:left="0"
+      style:right="0"
+    >
+      {@render children?.()}
+      {#if isEmpty}
+        <!-- (optional) empty placeholder -->
+        {@render empty?.()}
+      {/if}
+    </section>
+
     {#each assetStore.buckets as bucket (bucket.viewId)}
-      {@const isPremeasure = preMeasure.includes(bucket)}
-      {@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
+      {@const display = bucket.intersecting}
+      {@const absoluteHeight = bucket.top}
 
-      <div
-        class="bucket"
-        style:overflow={bucket.measured ? 'visible' : 'clip'}
-        use:intersectionObserver={[
-          {
-            key: bucket.viewId,
-            onIntersect: () => handleIntersect(bucket),
-            onSeparate: () => handleSeparate(bucket),
-            top: BUCKET_INTERSECTION_ROOT_TOP,
-            bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
-            root: element,
-          },
-          {
-            key: bucket.viewId + '.bucketintersection',
-            onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
-            top: '0px',
-            bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
-            left: '0px',
-            right: '0px',
-          },
-        ]}
-        data-bucket-display={bucket.intersecting}
-        data-bucket-date={bucket.bucketDate}
-        style:height={bucket.bucketHeight + 'px'}
-      >
-        {#if display && !bucket.measured}
-          <MeasureDateGroup
-            {bucket}
-            {assetStore}
-            onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))}
-          ></MeasureDateGroup>
-        {/if}
-
-        {#if !display || !bucket.measured}
-          <Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} />
-        {/if}
-        {#if display && bucket.measured}
+      {#if !bucket.isLoaded}
+        <div
+          style:height={bucket.bucketHeight + 'px'}
+          style:position="absolute"
+          style:transform={`translate3d(0,${absoluteHeight}px,0)`}
+          style:width="100%"
+        >
+          <Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
+        </div>
+      {:else if display}
+        <div
+          class="bucket"
+          style:height={bucket.bucketHeight + 'px'}
+          style:position="absolute"
+          style:transform={`translate3d(0,${absoluteHeight}px,0)`}
+          style:width="100%"
+        >
           <AssetDateGroup
-            assetGridElement={element}
-            renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
-            renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
             {withStacked}
             {showArchiveIcon}
-            {assetStore}
             {assetInteraction}
             {isSelectionMode}
             {singleSelect}
-            {onScrollTarget}
-            {onAssetInGrid}
             {bucket}
-            viewport={safeViewport}
             onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
             onSelectAssetCandidates={handleSelectAssetCandidates}
             onSelectAssets={handleSelectAssets}
           />
-        {/if}
-      </div>
+        </div>
+      {/if}
     {/each}
-    <div class="h-[60px]"></div>
+    <!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
   </section>
 </section>
 
@@ -965,6 +801,9 @@
   }
 
   .bucket {
-    contain: layout size;
+    contain: layout size paint;
+    transform-style: flat;
+    backface-visibility: hidden;
+    transform-origin: center center;
   }
 </style>
diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte
deleted file mode 100644
index d3dabaa51d..0000000000
--- a/web/src/lib/components/photos-page/measure-date-group.svelte
+++ /dev/null
@@ -1,91 +0,0 @@
-<script lang="ts" module>
-  const recentTimes: number[] = [];
-  // TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  function adjustTunables(avg: number) {}
-  function addMeasure(time: number) {
-    recentTimes.push(time);
-    if (recentTimes.length > 10) {
-      recentTimes.shift();
-    }
-    const sum = recentTimes.reduce((acc: number, val: number) => {
-      return acc + val;
-    }, 0);
-    const avg = sum / recentTimes.length;
-    adjustTunables(avg);
-  }
-</script>
-
-<script lang="ts">
-  import { resizeObserver } from '$lib/actions/resize-observer';
-  import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets-store.svelte';
-
-  interface Props {
-    assetStore: AssetStore;
-    bucket: AssetBucket;
-    onMeasured: () => void;
-  }
-
-  let { assetStore, bucket, onMeasured }: Props = $props();
-
-  async function _measure(element: Element) {
-    try {
-      await bucket.complete;
-      const t1 = Date.now();
-      let heightPending = bucket.dateGroups.some((group) => !group.heightActual);
-      if (heightPending) {
-        const listener: BucketListener = (event) => {
-          const { type } = event;
-          if (type === 'height') {
-            const { bucket: changedBucket } = event;
-            if (changedBucket === bucket && type === 'height') {
-              heightPending = bucket.dateGroups.some((group) => !group.heightActual);
-              if (!heightPending) {
-                const height = element.getBoundingClientRect().height;
-                if (height !== 0) {
-                  assetStore.updateBucket(bucket.bucketDate, { height, measured: true });
-                }
-
-                onMeasured();
-                assetStore.removeListener(listener);
-                const t2 = Date.now();
-
-                addMeasure((t2 - t1) / bucket.bucketCount);
-              }
-            }
-          }
-        };
-        assetStore.addListener(listener);
-      }
-    } catch {
-      // ignore if complete rejects (canceled load)
-    }
-  }
-  function measure(element: Element) {
-    void _measure(element);
-  }
-</script>
-
-<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
-  {#each bucket.dateGroups as dateGroup (dateGroup.date)}
-    <div id="date-group" data-date-group={dateGroup.date}>
-      <div use:resizeObserver={({ height }) => assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}>
-        <div
-          class="flex z-[100] sticky top-[-1px] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
-          style:width={dateGroup.geometry.containerWidth + 'px'}
-        >
-          <span class="w-full truncate first-letter:capitalize">
-            {dateGroup.groupTitle}
-          </span>
-        </div>
-
-        <div
-          class="relative overflow-clip"
-          style:height={dateGroup.geometry!.containerHeight + 'px'}
-          style:width={dateGroup.geometry!.containerWidth + 'px'}
-          style:visibility="hidden"
-        ></div>
-      </div>
-    </div>
-  {/each}
-</section>
diff --git a/web/src/lib/components/photos-page/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte
index 601a40cce2..9d1ba69aec 100644
--- a/web/src/lib/components/photos-page/skeleton.svelte
+++ b/web/src/lib/components/photos-page/skeleton.svelte
@@ -1,30 +1,28 @@
 <script lang="ts">
   interface Props {
-    title?: string | null;
-    height?: string | null;
+    height: number;
+    title: string;
   }
 
-  let { title = null, height = null }: Props = $props();
+  let { height = 0, title }: Props = $props();
 </script>
 
-<div class="overflow-clip" style={`height: ${height}`}>
-  {#if title}
-    <div
-      class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
-    >
-      <span class="w-full truncate first-letter:capitalize">{title}</span>
-    </div>
-  {/if}
-  <div id="skeleton" style={`height: ${height}`}></div>
+<div class="overflow-clip" style:height={height + 'px'}>
+  <div
+    class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
+  >
+    {title}
+  </div>
+  <div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div>
 </div>
 
 <style>
-  #skeleton {
+  [data-skeleton] {
     background-image: url('/light_skeleton.png');
     background-repeat: repeat;
     background-size: 235px, 235px;
   }
-  :global(.dark) #skeleton {
+  :global(.dark) [data-skeleton] {
     background-image: url('/dark_skeleton.png');
   }
   @keyframes delayedVisibility {
@@ -32,8 +30,10 @@
       visibility: visible;
     }
   }
-  #skeleton {
+  [data-skeleton] {
     visibility: hidden;
-    animation: 0s linear 0.1s forwards delayedVisibility;
+    animation:
+      0s linear 0.1s forwards delayedVisibility,
+      pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
   }
 </style>
diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte
index ef0bf3cda7..dbd8ca5a61 100644
--- a/web/src/lib/components/shared-components/control-app-bar.svelte
+++ b/web/src/lib/components/shared-components/control-app-bar.svelte
@@ -69,7 +69,7 @@
 <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
   <div
     id="asset-selection-app-bar"
-    class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
+    class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 my-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
       forceDark && 'bg-immich-dark-gray text-white'
     }`}
   >
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
index e7f6bfc5f1..1625c92d3c 100644
--- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
+++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
@@ -8,13 +8,11 @@
   import type { Viewport } from '$lib/stores/assets-store.svelte';
   import { showDeleteModal } from '$lib/stores/preferences.store';
   import { deleteAssets } from '$lib/utils/actions';
-  import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils';
+  import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { handleError } from '$lib/utils/handle-error';
   import { navigate } from '$lib/utils/navigation';
-  import { calculateWidth } from '$lib/utils/timeline-util';
   import { type AssetResponseDto } from '@immich/sdk';
-  import justifiedLayout from 'justified-layout';
   import { t } from 'svelte-i18n';
   import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
   import ShowShortcuts from '../show-shortcuts.svelte';
@@ -22,6 +20,8 @@
   import { handlePromiseError } from '$lib/utils';
   import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
+  import { debounce } from 'lodash-es';
+  import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
 
   interface Props {
     assets: AssetResponseDto[];
@@ -53,11 +53,84 @@
 
   let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
 
+  let geometry: CommonJustifiedLayout | undefined = $state();
+
+  $effect(() => {
+    const _assets = assets;
+    updateSlidingWindow();
+
+    geometry = getJustifiedLayoutFromAssets(_assets, {
+      spacing: 2,
+      heightTolerance: 0.15,
+      rowHeight: 235,
+      rowWidth: Math.floor(viewport.width),
+    });
+  });
+
+  let assetLayouts = $derived.by(() => {
+    const assetLayout = [];
+    let containerHeight = 0;
+    let containerWidth = 0;
+    if (geometry) {
+      containerHeight = geometry.containerHeight;
+      containerWidth = geometry.containerWidth;
+      for (const [i, asset] of assets.entries()) {
+        const layout = {
+          asset,
+          top: geometry.getTop(i),
+          left: geometry.getLeft(i),
+          width: geometry.getWidth(i),
+          height: geometry.getHeight(i),
+        };
+        // 54 is the content height of the asset-selection-app-bar
+        const layoutTopWithOffset = layout.top + 54;
+        const layoutBottom = layoutTopWithOffset + layout.height;
+
+        const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
+        assetLayout.push({ ...layout, display });
+      }
+    }
+
+    return {
+      assetLayout,
+      containerHeight,
+      containerWidth,
+    };
+  });
+
   let showShortcuts = $state(false);
   let currentViewAssetIndex = 0;
   let shiftKeyIsDown = $state(false);
   let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
+  let slidingWindow = $state({ top: 0, bottom: 0 });
 
+  const updateSlidingWindow = () => {
+    const v = $state.snapshot(viewport);
+    const top = document.scrollingElement?.scrollTop || 0;
+    const bottom = top + v.height;
+    const w = {
+      top,
+      bottom,
+    };
+    slidingWindow = w;
+  };
+  const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
+
+  let lastIntersectedHeight = 0;
+  $effect(() => {
+    // notify we got to (near) the end of scroll
+    const scrollPercentage =
+      ((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) *
+      100;
+
+    if (scrollPercentage > 90) {
+      const intersectedHeight = geometry?.containerHeight || 0;
+      if (lastIntersectedHeight !== intersectedHeight) {
+        debouncedOnIntersected();
+        lastIntersectedHeight = intersectedHeight;
+      }
+    }
+  });
   const viewAssetHandler = async (asset: AssetResponseDto) => {
     currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
     setAsset(assets[currentViewAssetIndex]);
@@ -75,6 +148,7 @@
   const onKeyDown = (event: KeyboardEvent) => {
     if (event.key === 'Shift') {
       event.preventDefault();
+
       shiftKeyIsDown = true;
     }
   };
@@ -90,7 +164,7 @@
     if (!asset) {
       return;
     }
-    const deselect = assetInteraction.selectedAssets.has(asset);
+    const deselect = assetInteraction.hasSelectedAsset(asset.id);
 
     // Select/deselect already loaded assets
     if (deselect) {
@@ -173,7 +247,7 @@
   const toggleArchive = async () => {
     const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
     if (ids) {
-      assets.filter((asset) => !ids.includes(asset.id));
+      assets = assets.filter((asset) => !ids.includes(asset.id));
       deselectAllAssets();
     }
   };
@@ -248,7 +322,7 @@
     }
   };
 
-  const handleRandom = async (): Promise<AssetResponseDto | null> => {
+  const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
     try {
       let asset: AssetResponseDto | undefined;
       if (onRandom) {
@@ -261,14 +335,14 @@
       }
 
       if (!asset) {
-        return null;
+        return;
       }
 
       await navigateToAsset(asset);
       return asset;
     } catch (error) {
       handleError(error, $t('errors.cannot_navigate_next_asset'));
-      return null;
+      return;
     }
   };
 
@@ -335,26 +409,6 @@
   let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
   let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
 
-  let geometry = $derived(
-    (() => {
-      const justifiedLayoutResult = justifiedLayout(
-        assets.map((asset) => getAssetRatio(asset)),
-        {
-          boxSpacing: 2,
-          containerWidth: Math.floor(viewport.width),
-          containerPadding: 0,
-          targetRowHeightTolerance: 0.15,
-          targetRowHeight: 235,
-        },
-      );
-
-      return {
-        ...justifiedLayoutResult,
-        containerWidth: calculateWidth(justifiedLayoutResult.boxes),
-      };
-    })(),
-  );
-
   $effect(() => {
     if (!lastAssetMouseEvent) {
       assetInteraction.clearAssetSelectionCandidates();
@@ -374,7 +428,13 @@
   });
 </script>
 
-<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
+<svelte:window
+  onkeydown={onKeyDown}
+  onkeyup={onKeyUp}
+  onselectstart={onSelectStart}
+  use:shortcuts={shortcutList}
+  onscroll={() => updateSlidingWindow()}
+/>
 
 {#if isShowDeleteConfirmation}
   <DeleteAssetDialog
@@ -389,43 +449,50 @@
 {/if}
 
 {#if assets.length > 0}
-  <div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
-    {#each assets as asset, i (i)}
-      <div
-        class="absolute"
-        style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
-          .top}px; left: {geometry.boxes[i].left}px"
-        title={showAssetName ? asset.originalFileName : ''}
-      >
-        <Thumbnail
-          readonly={disableAssetSelect}
-          onClick={(asset) => {
-            if (assetInteraction.selectionActive) {
-              handleSelectAssets(asset);
-              return;
-            }
-            void viewAssetHandler(asset);
-          }}
-          onSelect={(asset) => handleSelectAssets(asset)}
-          onMouseEvent={() => assetMouseEventHandler(asset)}
-          handleFocus={() => assetOnFocusHandler(asset)}
-          onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
-          {showArchiveIcon}
-          {asset}
-          selected={assetInteraction.selectedAssets.has(asset)}
-          focussed={assetInteraction.isFocussedAsset(asset)}
-          selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
-          thumbnailWidth={geometry.boxes[i].width}
-          thumbnailHeight={geometry.boxes[i].height}
-        />
-        {#if showAssetName}
-          <div
-            class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
-          >
-            {asset.originalFileName}
-          </div>
-        {/if}
-      </div>
+  <div
+    style:position="relative"
+    style:height={assetLayouts.containerHeight + 'px'}
+    style:width={assetLayouts.containerWidth - 1 + 'px'}
+  >
+    {#each assetLayouts.assetLayout as layout (layout.asset.id)}
+      {@const asset = layout.asset}
+
+      {#if layout.display}
+        <div
+          class="absolute"
+          style:overflow="clip"
+          style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
+          title={showAssetName ? asset.originalFileName : ''}
+        >
+          <Thumbnail
+            readonly={disableAssetSelect}
+            onClick={(asset) => {
+              if (assetInteraction.selectionActive) {
+                handleSelectAssets(asset);
+                return;
+              }
+              void viewAssetHandler(asset);
+            }}
+            onSelect={(asset) => handleSelectAssets(asset)}
+            onMouseEvent={() => assetMouseEventHandler(asset)}
+            handleFocus={() => assetOnFocusHandler(asset)}
+            {showArchiveIcon}
+            {asset}
+            selected={assetInteraction.hasSelectedAsset(asset.id)}
+            selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
+            focussed={assetInteraction.isFocussedAsset(asset)}
+            thumbnailWidth={layout.width}
+            thumbnailHeight={layout.height}
+          />
+          {#if showAssetName}
+            <div
+              class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
+            >
+              {asset.originalFileName}
+            </div>
+          {/if}
+        </div>
+      {/if}
     {/each}
   </div>
 {/if}
diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
index 729810d022..d13c12cf6a 100644
--- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte
+++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
@@ -1,10 +1,8 @@
 <script lang="ts">
-  import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets-store.svelte';
-  import { DateTime } from 'luxon';
+  import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
   import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
   import { clamp } from 'lodash-es';
-  import { onMount } from 'svelte';
-  import { isTimelineScrolling } from '$lib/stores/timeline.store';
+  import { DateTime } from 'luxon';
   import { fade, fly } from 'svelte/transition';
 
   interface Props {
@@ -15,11 +13,12 @@
     invisible?: boolean;
     scrubOverallPercent?: number;
     scrubBucketPercent?: number;
-    scrubBucket?: { bucketDate: string | undefined } | undefined;
+    scrubBucket?: { bucketDate: string | undefined };
     leadout?: boolean;
-    onScrub?: ScrubberListener | undefined;
-    startScrub?: ScrubberListener | undefined;
-    stopScrub?: ScrubberListener | undefined;
+    onScrub?: ScrubberListener;
+    onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
+    startScrub?: ScrubberListener;
+    stopScrub?: ScrubberListener;
   }
 
   let {
@@ -27,25 +26,22 @@
     timelineBottomOffset = 0,
     height = 0,
     assetStore,
-    invisible = false,
     scrubOverallPercent = 0,
     scrubBucketPercent = 0,
     scrubBucket = undefined,
     leadout = false,
     onScrub = undefined,
+    onScrubKeyDown = undefined,
     startScrub = undefined,
     stopScrub = undefined,
   }: Props = $props();
 
   let isHover = $state(false);
   let isDragging = $state(false);
-  let hoverLabel: string | undefined = $state();
-  let bucketDate: string | undefined;
   let hoverY = $state(0);
   let clientY = 0;
   let windowHeight = $state(0);
   let scrollBar: HTMLElement | undefined = $state();
-  let segments: Segment[] = $state([]);
 
   const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
   const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
@@ -87,28 +83,11 @@
       return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
     }
   };
-  let scrollY = $state(0);
-  $effect(() => {
-    scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
-  });
-
-  let timelineFullHeight = $derived(assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
+  let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
+  let timelineFullHeight = $derived(assetStore.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
   let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
   let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
 
-  const listener: BucketListener = (event) => {
-    const { type } = event;
-    if (type === 'viewport') {
-      segments = calculateSegments(assetStore.buckets);
-      scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
-    }
-  };
-
-  onMount(() => {
-    assetStore.addListener(listener);
-    return () => assetStore.removeListener(listener);
-  });
-
   type Segment = {
     count: number;
     height: number;
@@ -119,7 +98,7 @@
     hasDot: boolean;
   };
 
-  const calculateSegments = (buckets: AssetBucket[]) => {
+  const calculateSegments = (buckets: LiteBucket[]) => {
     let height = 0;
     let dotHeight = 0;
 
@@ -127,11 +106,10 @@
     let previousLabeledSegment: Segment | undefined;
 
     for (const [i, bucket] of buckets.entries()) {
-      const scrollBarPercentage =
-        bucket.bucketHeight / (assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
+      const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
 
       const segment = {
-        count: bucket.assets.length,
+        count: bucket.assetCount,
         height: toScrollY(scrollBarPercentage),
         bucketDate: bucket.bucketDate,
         date: fromLocalDateTime(bucket.bucketDate),
@@ -161,14 +139,23 @@
       segments.push(segment);
     }
 
-    hoverLabel = segments[0]?.dateFormatted;
     return segments;
   };
-
-  const updateLabel = (segment: HTMLElement) => {
-    hoverLabel = segment.dataset.label;
-    bucketDate = segment.dataset.timeSegmentBucketDate;
-  };
+  let activeSegment: HTMLElement | undefined = $state();
+  const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
+  const hoverLabel = $derived(activeSegment?.dataset.label);
+  const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
+  const scrollHoverLabel = $derived.by(() => {
+    const y = scrollY;
+    let cur = 0;
+    for (const segment of segments) {
+      if (y <= cur + segment.height + relativeTopOffset) {
+        return segment.dateFormatted;
+      }
+      cur += segment.height;
+    }
+    return '';
+  });
 
   const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
     const wasDragging = isDragging;
@@ -189,7 +176,8 @@
     const segment = elems.find(({ id }) => id === 'time-segment');
     let bucketPercentY = 0;
     if (segment) {
-      updateLabel(segment as HTMLElement);
+      activeSegment = segment as HTMLElement;
+
       const sr = segment.getBoundingClientRect();
       const sy = sr.y;
       const relativeY = clientY - sy;
@@ -197,9 +185,9 @@
     } else {
       const leadin = elems.find(({ id }) => id === 'lead-in');
       if (leadin) {
-        updateLabel(leadin as HTMLElement);
+        activeSegment = leadin as HTMLElement;
       } else {
-        bucketDate = undefined;
+        activeSegment = undefined;
         bucketPercentY = 0;
       }
     }
@@ -230,27 +218,34 @@
   onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
 />
 
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-
 <div
   transition:fly={{ x: 50, duration: 250 }}
+  tabindex="-1"
+  role="scrollbar"
+  aria-controls="time-label"
+  aria-valuenow={scrollY + HOVER_DATE_HEIGHT}
+  aria-valuemax={toScrollY(100)}
+  aria-valuemin={toScrollY(0)}
   id="immich-scrubbable-scrollbar"
   class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
   style:padding-top={HOVER_DATE_HEIGHT + 'px'}
   style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
-  class:invisible
   style:width={isDragging ? '100vw' : '60px'}
   style:height={height + 'px'}
   style:background-color={isDragging ? 'transparent' : 'transparent'}
-  draggable="false"
   bind:this={scrollBar}
   onmouseenter={() => (isHover = true)}
   onmouseleave={() => (isHover = false)}
+  onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
 >
   {#if hoverLabel && (isHover || isDragging)}
     <div
       id="time-label"
-      class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
+      class={[
+        { 'border-b-2': isDragging },
+        { 'rounded-bl-md': !isDragging },
+        'truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md  border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg',
+      ]}
       style:top="{hoverY + 2}px"
     >
       {hoverLabel}
@@ -262,12 +257,12 @@
       class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
       style:top="{scrollY + HOVER_DATE_HEIGHT}px"
     >
-      {#if $isTimelineScrolling && scrubBucket?.bucketDate}
+      {#if assetStore.scrolling && scrollHoverLabel}
         <p
           transition:fade={{ duration: 200 }}
           class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
         >
-          {assetStore.getBucketByDate(scrubBucket.bucketDate)?.bucketDateFormattted}
+          {scrollHoverLabel}
         </p>
       {/if}
     </div>
diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
index f14a836be6..3faa87f28f 100644
--- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
+++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
@@ -121,15 +121,14 @@
 
 <Portal target="body">
   {#if showMessage}
-    <div
+    <dialog
+      open
       class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
       transition:fade={{ duration: 150 }}
       onmouseover={() => (hoverMessage = true)}
       onmouseleave={() => (hoverMessage = false)}
       onfocus={() => (hoverMessage = true)}
       onblur={() => (hoverMessage = false)}
-      role="dialog"
-      tabindex="0"
     >
       <div class="flex justify-between place-items-center">
         <div class="h-10 w-10">
@@ -178,6 +177,12 @@
           {$t('purchase_button_reminder')}
         </Button>
       </div>
-    </div>
+    </dialog>
   {/if}
 </Portal>
+
+<style>
+  dialog {
+    margin: 0;
+  }
+</style>
diff --git a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
index 6ed915e683..7da2215a77 100644
--- a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
+++ b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
@@ -45,7 +45,7 @@
           onclick={() => {}}
         />
       </li>
-      {#each pathSegments as segment, index (segment)}
+      {#each pathSegments as segment, index (index)}
         {@const isLastSegment = index === pathSegments.length - 1}
         <li
           class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
index e6b9954349..e59e2daa70 100644
--- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
+++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
@@ -62,7 +62,7 @@
 
   const onRandom = () => {
     if (assets.length <= 0) {
-      return Promise.resolve(null);
+      return Promise.resolve(undefined);
     }
     const index = Math.floor(Math.random() * assets.length);
     const asset = assets[index];
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index b296e49f17..84173fe944 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -358,15 +358,24 @@ export enum SettingInputFieldType {
   COLOR = 'color',
 }
 
-export enum AlbumPageViewMode {
-  LINK_SHARING = 'link-sharing',
-  SELECT_USERS = 'select-users',
-  SELECT_THUMBNAIL = 'select-thumbnail',
-  SELECT_ASSETS = 'select-assets',
-  VIEW_USERS = 'view-users',
-  VIEW = 'view',
-  OPTIONS = 'options',
-}
+export const AlbumPageViewMode = {
+  LINK_SHARING: 'link-sharing',
+  SELECT_USERS: 'select-users',
+  SELECT_THUMBNAIL: 'select-thumbnail',
+  SELECT_ASSETS: 'select-assets',
+  VIEW_USERS: 'view-users',
+  VIEW: 'view',
+  OPTIONS: 'options',
+};
+
+export type AlbumPageViewMode =
+  | typeof AlbumPageViewMode.LINK_SHARING
+  | typeof AlbumPageViewMode.SELECT_USERS
+  | typeof AlbumPageViewMode.SELECT_THUMBNAIL
+  | typeof AlbumPageViewMode.SELECT_ASSETS
+  | typeof AlbumPageViewMode.VIEW_USERS
+  | typeof AlbumPageViewMode.VIEW
+  | typeof AlbumPageViewMode.OPTIONS;
 
 export enum PersonPageViewMode {
   VIEW_ASSETS = 'view-assets',
diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts
index fb91af579d..c940e6cec7 100644
--- a/web/src/lib/stores/asset-interaction.svelte.ts
+++ b/web/src/lib/stores/asset-interaction.svelte.ts
@@ -5,8 +5,14 @@ import { fromStore } from 'svelte/store';
 
 export class AssetInteraction {
   readonly selectedAssets = new SvelteSet<AssetResponseDto>();
+  hasSelectedAsset(assetId: string) {
+    return [...this.selectedAssets.values()].some((asset) => asset.id === assetId);
+  }
   readonly selectedGroup = new SvelteSet<string>();
   assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>());
+  hasSelectionCandidate(assetId: string) {
+    return [...this.assetSelectionCandidates.values()].some((asset) => asset.id === assetId);
+  }
   assetSelectionStart = $state<AssetResponseDto | null>(null);
   focussedAssetId = $state<string | null>(null);
 
@@ -32,7 +38,10 @@ export class AssetInteraction {
   }
 
   removeAssetFromMultiselectGroup(asset: AssetResponseDto) {
-    this.selectedAssets.delete(asset);
+    const selectedAsset = [...this.selectedAssets.values()].find((a) => a.id === asset.id);
+    if (selectedAsset) {
+      this.selectedAssets.delete(selectedAsset);
+    }
   }
 
   addGroupToMultiselectGroup(group: string) {
diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts
index 4ee551bd7b..0685103a1b 100644
--- a/web/src/lib/stores/assets-store.spec.ts
+++ b/web/src/lib/stores/assets-store.spec.ts
@@ -12,21 +12,25 @@ describe('AssetStore', () => {
   describe('init', () => {
     let assetStore: AssetStore;
     const bucketAssets: Record<string, AssetResponseDto[]> = {
-      '2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
-      '2024-02-01T00:00:00.000Z': assetFactory.buildList(100),
-      '2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
+      '2024-03-01T00:00:00.000Z': assetFactory
+        .buildList(1)
+        .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
+      '2024-02-01T00:00:00.000Z': assetFactory
+        .buildList(100)
+        .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
+      '2024-01-01T00:00:00.000Z': assetFactory
+        .buildList(3)
+        .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
     };
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([
         { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
         { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
         { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
       ]);
       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
-
-      await assetStore.init();
       await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
@@ -37,51 +41,57 @@ describe('AssetStore', () => {
     });
 
     it('calculates bucket height', () => {
-      expect(assetStore.buckets).toEqual(
+      const plainBuckets = assetStore.buckets.map((bucket) => ({
+        bucketDate: bucket.bucketDate,
+        bucketHeight: bucket.bucketHeight,
+      }));
+
+      expect(plainBuckets).toEqual(
         expect.arrayContaining([
-          expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }),
-          expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }),
+          expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }),
+          expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }),
           expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
         ]),
       );
     });
 
     it('calculates timeline height', () => {
-      expect(assetStore.timelineHeight).toBe(4383);
+      expect(assetStore.timelineHeight).toBe(5105.333_333_333_333);
     });
   });
 
   describe('loadBucket', () => {
     let assetStore: AssetStore;
     const bucketAssets: Record<string, AssetResponseDto[]> = {
-      '2024-01-03T00:00:00.000Z': assetFactory.buildList(1),
-      '2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
+      '2024-01-03T00:00:00.000Z': assetFactory
+        .buildList(1)
+        .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
+      '2024-01-01T00:00:00.000Z': assetFactory
+        .buildList(3)
+        .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
     };
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([
-        { count: 1, timeBucket: '2024-01-03T00:00:00.000Z' },
+        { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
         { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
       ]);
       sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
-        // Allow request to be aborted
         await new Promise((resolve) => setTimeout(resolve, 0));
         if (signal?.aborted) {
           throw new AbortError();
         }
-
         return bucketAssets[timeBucket];
       });
-      await assetStore.init();
-      await assetStore.updateViewport({ width: 0, height: 0 });
+      await assetStore.updateViewport({ width: 1588, height: 0 });
     });
 
     it('loads a bucket', async () => {
-      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
+      expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
       await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
       expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
-      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3);
+      expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
     });
 
     it('ignores invalid buckets', async () => {
@@ -90,15 +100,13 @@ describe('AssetStore', () => {
     });
 
     it('cancels bucket loading', async () => {
-      const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
-      const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
-
-      const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort');
+      const bucket = assetStore.getBucketByDate(2024, 1)!;
+      void assetStore.loadBucket(bucket!.bucketDate);
+      const abortSpy = vi.spyOn(bucket!.loader!.cancelToken!, 'abort');
       bucket?.cancel();
       expect(abortSpy).toBeCalledTimes(1);
-
-      await loadPromise;
-      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
+      await assetStore.loadBucket(bucket!.bucketDate);
+      expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
     });
 
     it('prevents loading buckets multiple times', async () => {
@@ -113,15 +121,15 @@ describe('AssetStore', () => {
     });
 
     it('allows loading a canceled bucket', async () => {
-      const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
+      const bucket = assetStore.getBucketByDate(2024, 1)!;
       const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
 
-      bucket?.cancel();
+      bucket.cancel();
       await loadPromise;
-      expect(bucket?.assets.length).toEqual(0);
+      expect(bucket?.getAssets().length).toEqual(0);
 
-      await assetStore.loadBucket(bucket!.bucketDate);
-      expect(bucket!.assets.length).toEqual(3);
+      await assetStore.loadBucket(bucket.bucketDate);
+      expect(bucket!.getAssets().length).toEqual(3);
     });
   });
 
@@ -129,15 +137,15 @@ describe('AssetStore', () => {
     let assetStore: AssetStore;
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init();
+
       await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
     it('is empty initially', () => {
       expect(assetStore.buckets.length).toEqual(0);
-      expect(assetStore.assets.length).toEqual(0);
+      expect(assetStore.getAssets().length).toEqual(0);
     });
 
     it('adds assets to new bucket', () => {
@@ -148,10 +156,10 @@ describe('AssetStore', () => {
       assetStore.addAssets([asset]);
 
       expect(assetStore.buckets.length).toEqual(1);
-      expect(assetStore.assets.length).toEqual(1);
-      expect(assetStore.buckets[0].assets.length).toEqual(1);
+      expect(assetStore.getAssets().length).toEqual(1);
+      expect(assetStore.buckets[0].getAssets().length).toEqual(1);
       expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
-      expect(assetStore.assets[0].id).toEqual(asset.id);
+      expect(assetStore.getAssets()[0].id).toEqual(asset.id);
     });
 
     it('adds assets to existing bucket', () => {
@@ -163,8 +171,8 @@ describe('AssetStore', () => {
       assetStore.addAssets([assetTwo]);
 
       expect(assetStore.buckets.length).toEqual(1);
-      expect(assetStore.assets.length).toEqual(2);
-      expect(assetStore.buckets[0].assets.length).toEqual(2);
+      expect(assetStore.getAssets().length).toEqual(2);
+      expect(assetStore.buckets[0].getAssets().length).toEqual(2);
       expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
     });
 
@@ -183,12 +191,12 @@ describe('AssetStore', () => {
       });
       assetStore.addAssets([assetOne, assetTwo, assetThree]);
 
-      const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
+      const bucket = assetStore.getBucketByDate(2024, 1);
       expect(bucket).not.toBeNull();
-      expect(bucket?.assets.length).toEqual(3);
-      expect(bucket?.assets[0].id).toEqual(assetOne.id);
-      expect(bucket?.assets[1].id).toEqual(assetThree.id);
-      expect(bucket?.assets[2].id).toEqual(assetTwo.id);
+      expect(bucket?.getAssets().length).toEqual(3);
+      expect(bucket?.getAssets()[0].id).toEqual(assetOne.id);
+      expect(bucket?.getAssets()[1].id).toEqual(assetThree.id);
+      expect(bucket?.getAssets()[2].id).toEqual(assetTwo.id);
     });
 
     it('orders buckets by descending date', () => {
@@ -210,17 +218,18 @@ describe('AssetStore', () => {
 
       assetStore.addAssets([asset]);
       expect(updateAssetsSpy).toBeCalledWith([asset]);
-      expect(assetStore.assets.length).toEqual(1);
+      expect(assetStore.getAssets().length).toEqual(1);
     });
 
     // disabled due to the wasm Justified Layout import
-    it.skip('ignores trashed assets when isTrashed is true', () => {
+    it('ignores trashed assets when isTrashed is true', async () => {
       const asset = assetFactory.build({ isTrashed: false });
       const trashedAsset = assetFactory.build({ isTrashed: true });
 
-      const assetStore = new AssetStore({ isTrashed: true });
+      const assetStore = new AssetStore();
+      await assetStore.updateOptions({ isTrashed: true });
       assetStore.addAssets([asset, trashedAsset]);
-      expect(assetStore.assets).toEqual([trashedAsset]);
+      expect(assetStore.getAssets()).toEqual([trashedAsset]);
     });
   });
 
@@ -228,9 +237,9 @@ describe('AssetStore', () => {
     let assetStore: AssetStore;
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init();
+
       await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
@@ -238,7 +247,7 @@ describe('AssetStore', () => {
       assetStore.updateAssets([assetFactory.build()]);
 
       expect(assetStore.buckets.length).toEqual(0);
-      expect(assetStore.assets.length).toEqual(0);
+      expect(assetStore.getAssets().length).toEqual(0);
     });
 
     it('updates an asset', () => {
@@ -246,26 +255,29 @@ describe('AssetStore', () => {
       const updatedAsset = { ...asset, isFavorite: true };
 
       assetStore.addAssets([asset]);
-      expect(assetStore.assets.length).toEqual(1);
-      expect(assetStore.assets[0].isFavorite).toEqual(false);
+      expect(assetStore.getAssets().length).toEqual(1);
+      expect(assetStore.getAssets()[0].isFavorite).toEqual(false);
 
       assetStore.updateAssets([updatedAsset]);
-      expect(assetStore.assets.length).toEqual(1);
-      expect(assetStore.assets[0].isFavorite).toEqual(true);
+      expect(assetStore.getAssets().length).toEqual(1);
+      expect(assetStore.getAssets()[0].isFavorite).toEqual(true);
     });
 
-    it('replaces bucket date when asset date changes', () => {
+    it('asset moves buckets when asset date changes', () => {
       const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
       const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
 
       assetStore.addAssets([asset]);
       expect(assetStore.buckets.length).toEqual(1);
-      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).not.toBeNull();
+      expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
+      expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(1);
 
       assetStore.updateAssets([updatedAsset]);
-      expect(assetStore.buckets.length).toEqual(1);
-      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).toBeNull();
-      expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).not.toBeNull();
+      expect(assetStore.buckets.length).toEqual(2);
+      expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
+      expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
+      expect(assetStore.getBucketByDate(2024, 3)).not.toBeUndefined();
+      expect(assetStore.getBucketByDate(2024, 3)?.getAssets().length).toEqual(1);
     });
   });
 
@@ -273,9 +285,9 @@ describe('AssetStore', () => {
     let assetStore: AssetStore;
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init();
+
       await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
@@ -283,9 +295,9 @@ describe('AssetStore', () => {
       assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
       assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
 
-      expect(assetStore.assets.length).toEqual(2);
+      expect(assetStore.getAssets().length).toEqual(2);
       expect(assetStore.buckets.length).toEqual(1);
-      expect(assetStore.buckets[0].assets.length).toEqual(2);
+      expect(assetStore.buckets[0].getAssets().length).toEqual(2);
     });
 
     it('removes asset from bucket', () => {
@@ -293,18 +305,18 @@ describe('AssetStore', () => {
       assetStore.addAssets([assetOne, assetTwo]);
       assetStore.removeAssets([assetOne.id]);
 
-      expect(assetStore.assets.length).toEqual(1);
+      expect(assetStore.getAssets().length).toEqual(1);
       expect(assetStore.buckets.length).toEqual(1);
-      expect(assetStore.buckets[0].assets.length).toEqual(1);
+      expect(assetStore.buckets[0].getAssets().length).toEqual(1);
     });
 
-    it('removes bucket when empty', () => {
+    it('does not remove bucket when empty', () => {
       const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
       assetStore.addAssets(assets);
       assetStore.removeAssets(assets.map((asset) => asset.id));
 
-      expect(assetStore.assets.length).toEqual(0);
-      expect(assetStore.buckets.length).toEqual(0);
+      expect(assetStore.getAssets().length).toEqual(0);
+      expect(assetStore.buckets.length).toEqual(1);
     });
   });
 
@@ -312,14 +324,13 @@ describe('AssetStore', () => {
     let assetStore: AssetStore;
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init();
       await assetStore.updateViewport({ width: 0, height: 0 });
     });
 
     it('empty store returns null', () => {
-      expect(assetStore.getFirstAsset()).toBeNull();
+      expect(assetStore.getFirstAsset()).toBeUndefined();
     });
 
     it('populated store returns first asset', () => {
@@ -339,13 +350,19 @@ describe('AssetStore', () => {
   describe('getPreviousAsset', () => {
     let assetStore: AssetStore;
     const bucketAssets: Record<string, AssetResponseDto[]> = {
-      '2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
-      '2024-02-01T00:00:00.000Z': assetFactory.buildList(6),
-      '2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
+      '2024-03-01T00:00:00.000Z': assetFactory
+        .buildList(1)
+        .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
+      '2024-02-01T00:00:00.000Z': assetFactory
+        .buildList(6)
+        .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
+      '2024-01-01T00:00:00.000Z': assetFactory
+        .buildList(3)
+        .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
     };
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([
         { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
         { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
@@ -353,38 +370,46 @@ describe('AssetStore', () => {
       ]);
       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
 
-      await assetStore.init();
-      await assetStore.updateViewport({ width: 0, height: 0 });
+      await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
     it('returns null for invalid assetId', async () => {
       expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
-      expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeNull();
+      expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
     });
 
     it('returns previous assetId', async () => {
       await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
-      const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
+      const bucket = assetStore.getBucketByDate(2024, 1);
 
-      expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]);
+      const a = bucket!.getAssets()[0];
+      const b = bucket!.getAssets()[1];
+      const previous = await assetStore.getPreviousAsset(b);
+      expect(previous).toEqual(a);
     });
 
     it('returns previous assetId spanning multiple buckets', async () => {
       await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
       await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
 
-      const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
-      const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
-      expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]);
+      const bucket = assetStore.getBucketByDate(2024, 2);
+      const previousBucket = assetStore.getBucketByDate(2024, 3);
+      const a = bucket!.getAssets()[0];
+      const b = previousBucket!.getAssets()[0];
+      const previous = await assetStore.getPreviousAsset(a);
+      expect(previous).toEqual(b);
     });
 
     it('loads previous bucket', async () => {
       await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
 
       const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
-      const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
-      const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
-      expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]);
+      const bucket = assetStore.getBucketByDate(2024, 2);
+      const previousBucket = assetStore.getBucketByDate(2024, 3);
+      const a = bucket!.getAssets()[0];
+      const b = previousBucket!.getAssets()[0];
+      const previous = await assetStore.getPreviousAsset(a);
+      expect(previous).toEqual(b);
       expect(loadBucketSpy).toBeCalledTimes(1);
     });
 
@@ -393,14 +418,14 @@ describe('AssetStore', () => {
       await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
       await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
 
-      const [assetOne, assetTwo, assetThree] = assetStore.assets;
+      const [assetOne, assetTwo, assetThree] = assetStore.getAssets();
       assetStore.removeAssets([assetTwo.id]);
       expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne);
     });
 
     it('returns null when no more assets', async () => {
       await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
-      expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull();
+      expect(await assetStore.getPreviousAsset(assetStore.getAssets()[0])).toBeUndefined();
     });
   });
 
@@ -408,15 +433,15 @@ describe('AssetStore', () => {
     let assetStore: AssetStore;
 
     beforeEach(async () => {
-      assetStore = new AssetStore({});
+      assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init();
+
       await assetStore.updateViewport({ width: 0, height: 0 });
     });
 
     it('returns null for invalid buckets', () => {
-      expect(assetStore.getBucketByDate('invalid')).toBeNull();
-      expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).toBeNull();
+      expect(assetStore.getBucketByDate(-1, -1)).toBeUndefined();
+      expect(assetStore.getBucketByDate(2024, 3)).toBeUndefined();
     });
 
     it('returns the bucket index', () => {
@@ -424,8 +449,8 @@ describe('AssetStore', () => {
       const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
       assetStore.addAssets([assetOne, assetTwo]);
 
-      expect(assetStore.getBucketIndexByAssetId(assetTwo.id)).toEqual(0);
-      expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(1);
+      expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
+      expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
     });
 
     it('ignores removed buckets', () => {
@@ -434,7 +459,7 @@ describe('AssetStore', () => {
       assetStore.addAssets([assetOne, assetTwo]);
 
       assetStore.removeAssets([assetTwo.id]);
-      expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(0);
+      expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
     });
   });
 });
diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts
index c8d2bc7979..249de56a84 100644
--- a/web/src/lib/stores/assets-store.svelte.ts
+++ b/web/src/lib/stores/assets-store.svelte.ts
@@ -1,23 +1,201 @@
 import { locale } from '$lib/stores/preferences.store';
 import { getKey } from '$lib/utils';
-import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
-import { generateId } from '$lib/utils/generate-id';
-import { type getJustifiedLayoutFromAssetsFunction } from '$lib/utils/layout-utils';
-import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
-import { fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
-import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
-import { throttle } from 'lodash-es';
+import { CancellableTask } from '$lib/utils/cancellable-task';
+import {
+  getJustifiedLayoutFromAssets,
+  getPosition,
+  type CommonLayoutOptions,
+  type CommonPosition,
+} from '$lib/utils/layout-utils';
+import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util';
+import { TUNABLES } from '$lib/utils/tunables';
+import { getAssetInfo, getTimeBucket, getTimeBuckets, TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
+import { debounce, isEqual, throttle } from 'lodash-es';
 import { DateTime } from 'luxon';
 import { t } from 'svelte-i18n';
+
 import { SvelteSet } from 'svelte/reactivity';
 import { get, writable, type Unsubscriber } from 'svelte/store';
 import { handleError } from '../utils/handle-error';
 import { websocketEvents } from './websocket';
 
-let getJustifiedLayoutFromAssets: getJustifiedLayoutFromAssetsFunction;
+const {
+  TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
+} = TUNABLES;
+
+const THUMBNAIL_HEIGHT = 235;
+const GAP = 12;
+const HEADER = 49; //(1.5rem)
 
 type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
-export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;
+export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
+  timelineAlbumId?: string;
+  deferInit?: boolean;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function updateObject(target: any, source: any): boolean {
+  if (!target) {
+    return false;
+  }
+  let updated = false;
+  for (const key in source) {
+    // eslint-disable-next-line no-prototype-builtins
+    if (!source.hasOwnProperty(key)) {
+      continue;
+    }
+    if (typeof target[key] === 'object') {
+      updated = updated || updateObject(target[key], source[key]);
+    } else {
+      // Otherwise, directly copy the value
+      if (target[key] !== source[key]) {
+        target[key] = source[key];
+        updated = true;
+      }
+    }
+  }
+  return updated;
+}
+
+class IntersectingAsset {
+  // --- public ---
+  readonly #group: AssetDateGroup;
+
+  intersecting = $derived.by(() => {
+    if (!this.position) {
+      return false;
+    }
+
+    const store = this.#group.bucket.store;
+    const topWindow = store.visibleWindow.top + HEADER - INTERSECTION_EXPAND_TOP;
+    const bottomWindow = store.visibleWindow.bottom + HEADER + INTERSECTION_EXPAND_BOTTOM;
+    const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
+    const positionBottom = positionTop + this.position.height;
+
+    const intersecting =
+      (positionTop >= topWindow && positionTop < bottomWindow) ||
+      (positionBottom >= topWindow && positionBottom < bottomWindow) ||
+      (positionTop < topWindow && positionBottom >= bottomWindow);
+    return intersecting;
+  });
+
+  position: CommonPosition | undefined = $state();
+  asset: AssetResponseDto | undefined = $state();
+  id: string = $derived.by(() => this.asset!.id);
+
+  constructor(group: AssetDateGroup, asset: AssetResponseDto) {
+    this.#group = group;
+    this.asset = asset;
+  }
+}
+type AssetOperation = (asset: AssetResponseDto) => { remove: boolean };
+
+type MoveAsset = { asset: AssetResponseDto; year: number; month: number };
+export class AssetDateGroup {
+  // --- public
+  readonly bucket: AssetBucket;
+  readonly index: number;
+  readonly date: DateTime;
+  readonly dayOfMonth: number;
+  intersetingAssets: IntersectingAsset[] = $state([]);
+  dodo: IntersectingAsset[] = $state([]);
+
+  height = $state(0);
+  width = $state(0);
+  intersecting = $derived.by(() => this.intersetingAssets.some((asset) => asset.intersecting));
+
+  // --- private
+  top: number = $state(0);
+  left: number = $state(0);
+  row = $state(0);
+  col = $state(0);
+
+  constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) {
+    this.index = index;
+    this.bucket = bucket;
+    this.date = date;
+    this.dayOfMonth = dayOfMonth;
+  }
+
+  sortAssets() {
+    this.intersetingAssets.sort((a, b) => {
+      const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC();
+      const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC();
+      return bDate.diff(aDate).milliseconds;
+    });
+  }
+
+  getFirstAsset() {
+    return this.intersetingAssets[0]?.asset;
+  }
+  getRandomAsset() {
+    const random = Math.floor(Math.random() * this.intersetingAssets.length);
+    return this.intersetingAssets[random];
+  }
+
+  getAssets() {
+    return this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!);
+  }
+
+  runAssetOperation(ids: Set<string>, operation: AssetOperation) {
+    if (ids.size === 0) {
+      return {
+        moveAssets: [] as MoveAsset[],
+        processedIds: new Set<string>(),
+        unprocessedIds: ids,
+        changedGeometry: false,
+      };
+    }
+    const unprocessedIds = new Set<string>(ids);
+    const processedIds = new Set<string>();
+    const moveAssets: MoveAsset[] = [];
+    let changedGeometry = false;
+    for (const assetId of unprocessedIds) {
+      const index = this.intersetingAssets.findIndex((ia) => ia.id == assetId);
+      if (index !== -1) {
+        const asset = this.intersetingAssets[index].asset!;
+        const oldTime = asset.localDateTime;
+        let { remove } = operation(asset);
+        const newTime = asset.localDateTime;
+        if (oldTime !== newTime) {
+          const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
+          const year = utc.get('year');
+          const month = utc.get('month');
+          if (this.bucket.year !== year || this.bucket.month !== month) {
+            remove = true;
+            moveAssets.push({ asset, year, month });
+          }
+        }
+        unprocessedIds.delete(assetId);
+        processedIds.add(assetId);
+        if (remove || this.bucket.store.isExcluded(asset)) {
+          this.intersetingAssets.splice(index, 1);
+          changedGeometry = true;
+        }
+      }
+    }
+    return { moveAssets, processedIds, unprocessedIds, changedGeometry };
+  }
+
+  layout(options: CommonLayoutOptions) {
+    const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!);
+    const geometry = getJustifiedLayoutFromAssets(assets, options);
+    this.width = geometry.containerWidth;
+    this.height = assets.length === 0 ? 0 : geometry.containerHeight;
+    for (let i = 0; i < this.intersetingAssets.length; i++) {
+      const position = getPosition(geometry, i);
+      this.intersetingAssets[i].position = position;
+    }
+  }
+
+  get absoluteDateGroupTop() {
+    return this.bucket.top + this.top;
+  }
+
+  get groupTitle() {
+    return formatDateGroupTitle(this.date);
+  }
+}
 
 export interface Viewport {
   width: number;
@@ -28,112 +206,276 @@ export type ViewportXY = Viewport & {
   y: number;
 };
 
-interface AssetLookup {
-  bucket: AssetBucket;
-  bucketIndex: number;
-  assetIndex: number;
-}
-
 export class AssetBucket {
-  store!: AssetStore;
-  bucketDate: string = $state('');
+  // --- public ---
+  #intersecting: boolean = $state(false);
+  isLoaded: boolean = $state(false);
+  dateGroups: AssetDateGroup[] = $state([]);
+  readonly store: AssetStore;
+
+  // --- private ---
   /**
    * The DOM height of the bucket in pixel
    * This value is first estimated by the number of asset and later is corrected as the user scroll
    * Do not derive this height, it is important for it to be updated at specific times, so that
    * calculateing a delta between estimated and actual (when measured) is correct.
    */
-  bucketHeight: number = $state(0);
-  isBucketHeightActual: boolean = $state(false);
-  bucketDateFormattted!: string;
-  bucketCount: number = $derived.by(() => (this.isLoaded ? this.assets.length : this.initialCount));
-  initialCount: number = 0;
-  assets: AssetResponseDto[] = $state([]);
-  dateGroups: DateGroup[] = $state([]);
-  cancelToken: AbortController | undefined = $state();
-  /**
-   * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset.
-   */
-  isPreventCancel: boolean = $state(false);
-  /**
-   * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
-   */
-  complete!: Promise<void>;
-  loading: boolean = $state(false);
-  isLoaded: boolean = $state(false);
-  intersecting: boolean = $state(false);
-  measured: boolean = $state(false);
-  measuredPromise!: Promise<void>;
+  #bucketHeight: number = $state(0);
+  #top: number = $state(0);
+  #initialCount: number = 0;
 
-  constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) {
-    Object.assign(this, props);
-    this.init();
+  // --- should be private, but is used by AssetStore ---
+
+  bucketCount: number = $derived(
+    this.isLoaded
+      ? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersetingAssets.length, 0)
+      : this.#initialCount,
+  );
+  loader: CancellableTask | undefined;
+  isBucketHeightActual: boolean = $state(false);
+
+  readonly bucketDateFormatted: string;
+  readonly bucketDate: string;
+  readonly month: number;
+  readonly year: number;
+
+  constructor(store: AssetStore, utcDate: DateTime, initialCount: number) {
+    this.store = store;
+    this.#initialCount = initialCount;
+
+    const year = utcDate.get('year');
+    const month = utcDate.get('month');
+    const bucketDateFormatted = utcDate.toJSDate().toLocaleString(get(locale), {
+      month: 'short',
+      year: 'numeric',
+      timeZone: 'UTC',
+    });
+    this.bucketDate = utcDate.toISO()!.toString();
+    this.bucketDateFormatted = bucketDateFormatted;
+    this.month = month;
+    this.year = year;
+
+    this.loader = new CancellableTask(
+      () => {
+        this.isLoaded = true;
+      },
+      () => {
+        this.isLoaded = false;
+      },
+      this.handleLoadError,
+    );
   }
+  set intersecting(newValue: boolean) {
+    const old = this.#intersecting;
+    if (old !== newValue) {
+      this.#intersecting = newValue;
+      if (newValue) {
+        void this.store.loadBucket(this.bucketDate);
+      } else {
+        this.cancel();
+      }
+    }
+  }
+
+  get intersecting() {
+    return this.#intersecting;
+  }
+
+  get lastDateGroup() {
+    return this.dateGroups.at(-1);
+  }
+  getFirstAsset() {
+    return this.dateGroups[0]?.getFirstAsset();
+  }
+  getAssets() {
+    // eslint-disable-next-line unicorn/no-array-reduce
+    return this.dateGroups.reduce(
+      (accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
+      [],
+    );
+  }
+
+  containsAssetId(id: string) {
+    for (const group of this.dateGroups) {
+      const index = group.intersetingAssets.findIndex((a) => a.id == id);
+      if (index !== -1) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  sortDateGroups() {
+    this.dateGroups.sort((a, b) => b.date.diff(a.date).milliseconds);
+  }
+
+  runAssetOperation(ids: Set<string>, operation: AssetOperation) {
+    if (ids.size === 0) {
+      return {
+        moveAssets: [] as MoveAsset[],
+        processedIds: new Set<string>(),
+        unprocessedIds: ids,
+        changedGeometry: false,
+      };
+    }
+    const { dateGroups } = this;
+    let combinedChangedGeometry = false;
+    let idsToProcess = new Set(ids);
+    const idsProcessed = new Set<string>();
+    const combinedMoveAssets: MoveAsset[][] = [];
+    let index = dateGroups.length;
+    while (index--) {
+      if (idsToProcess.size > 0) {
+        const group = dateGroups[index];
+        const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
+        if (moveAssets.length > 0) {
+          combinedMoveAssets.push(moveAssets);
+        }
+        idsToProcess = idsToProcess.difference(processedIds);
+        for (const id of processedIds) {
+          idsProcessed.add(id);
+        }
+        combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
+        if (group.intersetingAssets.length === 0) {
+          dateGroups.splice(index, 1);
+          combinedChangedGeometry = true;
+        }
+      }
+    }
+    return {
+      moveAssets: combinedMoveAssets.flat(),
+      unprocessedIds: idsToProcess,
+      processedIds: idsProcessed,
+      changedGeometry: combinedChangedGeometry,
+    };
+  }
+
+  // note - if the assets are not part of this bucket, they will not be added
+  addAssets(assets: AssetResponseDto[]) {
+    const lookupCache: {
+      [dayOfMonth: number]: AssetDateGroup;
+    } = {};
+    const unprocessedAssets: AssetResponseDto[] = [];
+    const changedDateGroups = new Set<AssetDateGroup>();
+    const newDateGroups = new Set<AssetDateGroup>();
+    for (const asset of assets) {
+      const date = DateTime.fromISO(asset.localDateTime).toUTC();
+      const month = date.get('month');
+      const year = date.get('year');
+      if (this.month === month && this.year === year) {
+        const day = date.get('day');
+        let dateGroup: AssetDateGroup | undefined = lookupCache[day];
+        if (!dateGroup) {
+          dateGroup = this.findDateGroupByDay(day);
+          if (dateGroup) {
+            lookupCache[day] = dateGroup;
+          }
+        }
+        if (dateGroup) {
+          const intersectingAsset = new IntersectingAsset(dateGroup, asset);
+          dateGroup.intersetingAssets.push(intersectingAsset);
+          changedDateGroups.add(dateGroup);
+        } else {
+          dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
+          dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset));
+          this.dateGroups.push(dateGroup);
+          lookupCache[day] = dateGroup;
+          newDateGroups.add(dateGroup);
+        }
+      } else {
+        unprocessedAssets.push(asset);
+      }
+    }
+    for (const group of changedDateGroups) {
+      group.sortAssets();
+    }
+    for (const group of newDateGroups) {
+      group.sortAssets();
+    }
+    if (newDateGroups.size > 0) {
+      this.sortDateGroups();
+    }
+    return unprocessedAssets;
+  }
+  getRandomDateGroup() {
+    const random = Math.floor(Math.random() * this.dateGroups.length);
+    return this.dateGroups[random];
+  }
+
+  getRandomAsset() {
+    return this.getRandomDateGroup()?.getRandomAsset()?.asset;
+  }
+
   /** The svelte key for this view model object */
   get viewId() {
-    return this.store.viewId + '-' + this.bucketDate;
-  }
-  private init() {
-    // create a promise, and store its resolve/reject callbacks. The loadedSignal callback
-    // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
-    // callback will be called if the bucket is canceled before it was loaded, rejecting the
-    // promise.
-    this.complete = new Promise<void>((resolve, reject) => {
-      this.loadedSignal = resolve;
-      this.canceledSignal = reject;
-    }).catch(
-      () =>
-        // if no-one waits on complete, and its rejected a uncaught rejection message is logged.
-        // We this message with an empty reject handler, since waiting on a bucket is optional.
-        void 0,
-    );
-
-    this.measuredPromise = new Promise((resolve) => {
-      this.measuredSignal = resolve;
-    });
-
-    this.bucketDateFormattted = fromLocalDateTime(this.bucketDate)
-      .startOf('month')
-      .toJSDate()
-      .toLocaleString(get(locale), {
-        month: 'short',
-        year: 'numeric',
-        timeZone: 'UTC',
-      });
+    return this.bucketDate;
   }
 
-  private loadedSignal: (() => void) | undefined;
-  private canceledSignal: (() => void) | undefined;
-  measuredSignal: (() => void) | undefined;
+  set bucketHeight(height: number) {
+    const { store } = this;
+    const index = store.buckets.indexOf(this);
+    const bucketHeightDelta = height - this.#bucketHeight;
+    const prevBucket = store.buckets[index - 1];
+    if (prevBucket) {
+      this.#top = prevBucket.#top + prevBucket.#bucketHeight;
+    }
+    if (bucketHeightDelta) {
+      let cursor = index + 1;
+      while (cursor < store.buckets.length) {
+        const nextBucket = this.store.buckets[cursor];
+        nextBucket.#top += bucketHeightDelta;
+        cursor++;
+      }
+    }
+    this.#bucketHeight = height;
+    if (store.topIntersectingBucket) {
+      const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
+      // if the bucket is 'before' the last intersecting bucket in the sliding window
+      // then adjust the scroll position by the delta, to compensate for the bucket
+      // size adjustment
+      if (currentIndex > 0 && index <= currentIndex) {
+        store.compensateScrollCallback?.(bucketHeightDelta);
+      }
+    }
+  }
+  get bucketHeight() {
+    return this.#bucketHeight;
+  }
+
+  set top(top: number) {
+    this.#top = top;
+  }
+  get top() {
+    return this.#top + this.store.topSectionHeight;
+  }
+
+  handleLoadError(error: unknown) {
+    const _$t = get(t);
+    handleError(error, _$t('errors.failed_to_load_assets'));
+  }
+
+  findDateGroupByDay(dayOfMonth: number) {
+    return this.dateGroups.find((group) => group.dayOfMonth === dayOfMonth);
+  }
+
+  findAssetAbsolutePosition(assetId: string) {
+    for (const group of this.dateGroups) {
+      const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId);
+      if (intersectingAsset) {
+        return this.top + group.top + intersectingAsset.position!.top + HEADER;
+      }
+    }
+    return -1;
+  }
 
   cancel() {
-    if (this.isLoaded) {
-      return;
-    }
-    if (this.isPreventCancel) {
-      return;
-    }
-    this.cancelToken?.abort();
-    this.canceledSignal?.();
-    this.init();
-  }
-
-  loaded() {
-    this.loadedSignal?.();
-    this.isLoaded = true;
-  }
-
-  errored() {
-    this.canceledSignal?.();
-    this.init();
+    this.loader?.cancel();
   }
 }
 
 const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
   option === undefined ? false : option !== value;
 
-const THUMBNAIL_HEIGHT = 235;
-
 interface AddAsset {
   type: 'add';
   values: AssetResponseDto[];
@@ -162,746 +504,719 @@ export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
 
 type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
 
-export type BucketListener = (
-  event:
-    | ViewPortEvent
-    | BucketLoadEvent
-    | BucketLoadedEvent
-    | BucketCancelEvent
-    | BucketHeightEvent
-    | DateGroupIntersecting
-    | DateGroupHeightEvent,
-) => void;
-
-type ViewPortEvent = {
-  type: 'viewport';
-};
-type BucketLoadEvent = {
-  type: 'load';
-  bucket: AssetBucket;
-};
-type BucketLoadedEvent = {
-  type: 'loaded';
-  bucket: AssetBucket;
-};
-type BucketCancelEvent = {
-  type: 'cancel';
-  bucket: AssetBucket;
-};
-type BucketHeightEvent = {
-  type: 'bucket-height';
-  bucket: AssetBucket;
-  delta: number;
-};
-type DateGroupIntersecting = {
-  type: 'intersecting';
-  bucket: AssetBucket;
-  dateGroup: DateGroup;
-};
-type DateGroupHeightEvent = {
-  type: 'height';
-  bucket: AssetBucket;
-  dateGroup: DateGroup;
-  delta: number;
-  height: number;
+export type LiteBucket = {
+  bucketHeight: number;
+  assetCount: number;
+  bucketDate: string;
+  bucketDateFormattted: string;
 };
 
 export class AssetStore {
-  private assetToBucket: Record<string, AssetLookup> = $derived.by(() => {
-    const result: Record<string, AssetLookup> = {};
-    for (let index = 0; index < this.buckets.length; index++) {
-      const bucket = this.buckets[index];
-      for (let index_ = 0; index_ < bucket.assets.length; index_++) {
-        const asset = bucket.assets[index_];
-        result[asset.id] = { bucket, bucketIndex: index, assetIndex: index_ };
-      }
-    }
-    return result;
-  });
-  private pendingChanges: PendingChange[] = [];
-  private unsubscribers: Unsubscriber[] = [];
-  private options!: AssetApiGetTimeBucketsRequest;
-  viewport: Viewport = $state({
-    height: 0,
-    width: 0,
-  });
-  private initializedSignal!: () => void;
-  private store$ = writable(this);
-
-  /** The svelte key for this view model object */
-  viewId = generateId();
-
-  lastScrollTime: number = $state(0);
-
-  // subscribe = this.store$.subscribe;
-  /**
-   * A promise that resolves once the store is initialized.
-   */
-  private complete!: Promise<void>;
-  taskManager = new AssetGridTaskManager(this);
-  initialized = $state(false);
-  timelineHeight = $state(0);
+  // --- public ----
+  isInitialized = $state(false);
   buckets: AssetBucket[] = $state([]);
-  assets: AssetResponseDto[] = $derived.by(() => {
-    return this.buckets.flatMap(({ assets }) => assets);
-  });
+  topSectionHeight = $state(0);
+  timelineHeight = $derived(
+    this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight,
+  );
+
+  // todo - name this better
   albumAssets: Set<string> = new SvelteSet();
-  pendingScrollBucket: AssetBucket | undefined = $state();
-  pendingScrollAssetId: string | undefined = $state();
-  maxBucketAssets = $state(0);
 
-  private listeners: BucketListener[] = [];
+  // -- for scrubber only
+  scrubberBuckets: LiteBucket[] = $state([]);
+  scrubberTimelineHeight: number = $state(0);
 
-  constructor(
-    options: AssetStoreOptions,
-    private albumId?: string,
-  ) {
-    this.setOptions(options);
-    this.createInitializationSignal();
-    this.store$.set(this);
+  // -- should be private, but used by AssetBucket
+  compensateScrollCallback: ((delta: number) => void) | undefined;
+  topIntersectingBucket: AssetBucket | undefined = $state();
+
+  visibleWindow = $derived.by(() => ({
+    top: this.#scrollTop,
+    bottom: this.#scrollTop + this.viewportHeight,
+  }));
+
+  initTask = new CancellableTask(
+    () => {
+      this.isInitialized = true;
+      this.connect();
+    },
+    () => {
+      this.disconnect();
+      this.isInitialized = false;
+    },
+    () => void 0,
+  );
+
+  // --- private
+  static #INIT_OPTIONS = {};
+  #viewportHeight = $state(0);
+  #viewportWidth = $state(0);
+  #scrollTop = $state(0);
+  #pendingChanges: PendingChange[] = [];
+  #unsubscribers: Unsubscriber[] = [];
+
+  #options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
+
+  #scrolling = $state(false);
+  #suspendTransitions = $state(false);
+  #resetScrolling = debounce(() => (this.#scrolling = false), 1000);
+  #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
+
+  constructor() {}
+
+  set scrolling(value: boolean) {
+    this.#scrolling = value;
+    if (value) {
+      this.suspendTransitions = true;
+      this.#resetScrolling();
+    }
   }
 
-  private setOptions(options: AssetStoreOptions) {
-    this.options = { ...options, size: TimeBucketSize.Month };
+  get scrolling() {
+    return this.#scrolling;
   }
 
-  private createInitializationSignal() {
-    // create a promise, and store its resolve callbacks. The initializedSignal callback
-    // will be invoked when a the assetstore is initialized.
-    this.complete = new Promise<void>((resolve) => {
-      this.initializedSignal = resolve;
-    }).catch(() => void 0);
+  set suspendTransitions(value: boolean) {
+    this.#suspendTransitions = value;
+    if (value) {
+      this.#resetSuspendTransitions();
+    }
   }
 
-  private addPendingChanges(...changes: PendingChange[]) {
-    // prevent websocket events from happening before local client events
-    setTimeout(() => {
-      this.pendingChanges.push(...changes);
-      this.processPendingChanges();
-    }, 1000);
+  get suspendTransitions() {
+    return this.#suspendTransitions;
+  }
+
+  set viewportWidth(value: number) {
+    const changed = value !== this.#viewportWidth;
+    this.#viewportWidth = value;
+    this.suspendTransitions = true;
+    // side-effect - its ok!
+    void this.#updateViewportGeometry(changed);
+  }
+
+  get viewportWidth() {
+    return this.#viewportWidth;
+  }
+
+  set viewportHeight(value: number) {
+    this.#viewportHeight = value;
+    this.#suspendTransitions = true;
+    // side-effect - its ok!
+    void this.#updateViewportGeometry(false);
+  }
+
+  get viewportHeight() {
+    return this.#viewportHeight;
+  }
+
+  getAssets() {
+    return this.buckets.flatMap((bucket) => bucket.getAssets());
+  }
+
+  #addPendingChanges(...changes: PendingChange[]) {
+    this.#pendingChanges.push(...changes);
+    this.#processPendingChanges();
   }
 
   connect() {
-    this.unsubscribers.push(
-      websocketEvents.on('on_upload_success', (_) => {
-        // TODO!: Temporarily disable to avoid flashing effect of the timeline
-        // this.addPendingChanges({ type: 'add', values: [asset] });
-      }),
-      websocketEvents.on('on_asset_trash', (ids) => {
-        this.addPendingChanges({ type: 'trash', values: ids });
-      }),
-      websocketEvents.on('on_asset_update', (asset) => {
-        this.addPendingChanges({ type: 'update', values: [asset] });
-      }),
-      websocketEvents.on('on_asset_delete', (id: string) => {
-        this.addPendingChanges({ type: 'delete', values: [id] });
-      }),
+    this.#unsubscribers.push(
+      websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })),
+      websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
+      websocketEvents.on('on_asset_update', (asset) => this.#addPendingChanges({ type: 'update', values: [asset] })),
+      websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
     );
   }
 
   disconnect() {
-    for (const unsubscribe of this.unsubscribers) {
+    for (const unsubscribe of this.#unsubscribers) {
       unsubscribe();
     }
-    this.unsubscribers = [];
+    this.#unsubscribers = [];
   }
 
-  private getPendingChangeBatches() {
-    const batches: PendingChange[] = [];
-    let batch: PendingChange | undefined;
-
-    for (const { type, values: _values } of this.pendingChanges) {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const values = _values as any[];
-
-      if (batch && batch.type !== type) {
-        batches.push(batch);
-        batch = undefined;
-      }
-
-      if (batch) {
-        batch.values.push(...values);
-      } else {
-        batch = { type, values };
-      }
-    }
-
-    if (batch) {
-      batches.push(batch);
-    }
-
-    return batches;
-  }
-
-  processPendingChanges = throttle(() => {
-    for (const { type, values } of this.getPendingChangeBatches()) {
+  #getPendingChangeBatches() {
+    const batch: {
+      add: AssetResponseDto[];
+      update: AssetResponseDto[];
+      remove: string[];
+    } = {
+      add: [],
+      update: [],
+      remove: [],
+    };
+    for (const { type, values } of this.#pendingChanges) {
       switch (type) {
         case 'add': {
-          this.addAssets(values);
+          batch.add.push(...values);
+
           break;
         }
-
         case 'update': {
-          this.updateAssets(values);
+          batch.update.push(...values);
+
           break;
         }
-
+        case 'delete':
         case 'trash': {
-          if (!this.options.isTrashed) {
-            this.removeAssets(values);
-          }
-          break;
-        }
+          batch.remove.push(...values);
 
-        case 'delete': {
-          this.removeAssets(values);
           break;
         }
+        // No default
       }
     }
+    return batch;
+  }
 
-    this.pendingChanges = [];
-    // this.emit(true);
+  // todo: this should probably be a method isteat
+  #findBucketForAsset(id: string) {
+    for (const bucket of this.buckets) {
+      if (bucket.containsAssetId(id)) {
+        return bucket;
+      }
+    }
+  }
+
+  updateSlidingWindow(scrollTop: number) {
+    this.#scrollTop = scrollTop;
+    this.updateIntersections();
+  }
+
+  updateIntersections() {
+    if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
+      return;
+    }
+    let topIntersectingBucket = undefined;
+    for (const bucket of this.buckets) {
+      this.#updateIntersection(bucket);
+      if (!topIntersectingBucket && bucket.intersecting) {
+        topIntersectingBucket = bucket;
+      }
+    }
+    if (this.topIntersectingBucket !== topIntersectingBucket) {
+      this.topIntersectingBucket = topIntersectingBucket;
+    }
+  }
+
+  #updateIntersection(bucket: AssetBucket) {
+    const bucketTop = bucket.top;
+    const bucketBottom = bucketTop + bucket.bucketHeight;
+    const topWindow = this.visibleWindow.top - INTERSECTION_EXPAND_TOP;
+    const bottomWindow = this.visibleWindow.bottom + INTERSECTION_EXPAND_BOTTOM;
+
+    // a bucket intersections if
+    // 1) bucket's bottom is in the visible range -or-
+    // 2) bucket's bottom is in the visible range -or-
+    // 3) bucket's top is above visible range and bottom is below visible range
+    bucket.intersecting =
+      (bucketTop >= topWindow && bucketTop < bottomWindow) ||
+      (bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
+      (bucketTop < topWindow && bucketBottom >= bottomWindow);
+  }
+
+  #processPendingChanges = throttle(() => {
+    const { add, update, remove } = this.#getPendingChangeBatches();
+    if (add.length > 0) {
+      this.addAssets(add);
+    }
+    if (update.length > 0) {
+      this.updateAssets(update);
+    }
+    if (remove.length > 0) {
+      this.removeAssets(remove);
+    }
+    this.#pendingChanges = [];
   }, 2500);
 
-  addListener(bucketListener: BucketListener) {
-    this.listeners.push(bucketListener);
-  }
-  removeListener(bucketListener: BucketListener) {
-    this.listeners = this.listeners.filter((l) => l != bucketListener);
-  }
-  private notifyListeners(
-    event:
-      | ViewPortEvent
-      | BucketLoadEvent
-      | BucketLoadedEvent
-      | BucketCancelEvent
-      | BucketHeightEvent
-      | DateGroupIntersecting
-      | DateGroupHeightEvent,
-  ) {
-    for (const fn of this.listeners) {
-      fn(event);
-    }
-  }
-  async init({ bucketListener }: { bucketListener?: BucketListener } = {}) {
-    if (this.initialized) {
-      throw 'Can only init once';
-    }
-    if (!getJustifiedLayoutFromAssets) {
-      const module = await import('$lib/utils/layout-utils');
-      getJustifiedLayoutFromAssets = module.getJustifiedLayoutFromAssets;
-    }
-
-    if (bucketListener) {
-      this.addListener(bucketListener);
-    }
-    await this.initialiazeTimeBuckets();
+  setCompensateScrollCallback(compensateScrollCallback?: (delta: number) => void) {
+    this.compensateScrollCallback = compensateScrollCallback;
   }
 
-  async initialiazeTimeBuckets() {
-    this.timelineHeight = 0;
-    this.buckets = [];
-    this.albumAssets.clear();
-
+  async #initialiazeTimeBuckets() {
     const timebuckets = await getTimeBuckets({
-      ...this.options,
+      ...this.#options,
+      size: TimeBucketSize.Month,
       key: getKey(),
     });
-    this.buckets = timebuckets.map(
-      (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, initialCount: bucket.count }),
-    );
 
-    this.initializedSignal();
-    this.initialized = true;
+    this.buckets = timebuckets.map((bucket) => {
+      const utcDate = DateTime.fromISO(bucket.timeBucket).toUTC();
+      return new AssetBucket(this, utcDate, bucket.count);
+    });
+    this.albumAssets.clear();
+    this.#updateViewportGeometry(false);
   }
 
+  /**
+   * If the timeline query options change (i.e. albumId, isArchived, isFavorite, etc)
+   * call this method to recreate all buckets based on the new options.
+   *
+   * @param options The query options for time bucket queries.
+   */
   async updateOptions(options: AssetStoreOptions) {
-    // Make sure to re-initialize if the personId changes
-    const needsReinitializing = this.options.personId !== options.personId;
-    if (!this.initialized && !needsReinitializing) {
-      this.setOptions(options);
+    if (options.deferInit) {
       return;
     }
-
-    // Make sure to re-initialize if the tagId changes
-    if (this.options.tagId === options.tagId) {
-      this.setOptions(options);
+    if (this.#options !== AssetStore.#INIT_OPTIONS && isEqual(this.#options, options)) {
       return;
     }
-    // TODO: don't call updateObjects frequently after
-    // init - cancelation of the initialize tasks isn't
-    // performed right now, and will cause issues if
-    // multiple updateOptions() calls are interleved.
-    await this.complete;
-    this.taskManager.destroy();
-    this.taskManager = new AssetGridTaskManager(this);
-    this.initialized = false;
-    this.viewId = generateId();
-    this.createInitializationSignal();
-    this.setOptions(options);
-    await this.initialiazeTimeBuckets();
-    // this.emit(true);
-    await this.initialLayout(true);
+    await this.initTask.reset();
+    await this.#init(options);
+    this.#updateViewportGeometry(false);
   }
 
+  async #init(options: AssetStoreOptions) {
+    // doing the following outside of the task reduces flickr
+    this.isInitialized = false;
+    this.buckets = [];
+    this.albumAssets.clear();
+    await this.initTask.execute(async () => {
+      this.#options = options;
+      await this.#initialiazeTimeBuckets();
+    }, true);
+  }
   public destroy() {
-    this.taskManager.destroy();
-    this.listeners = [];
-    this.initialized = false;
+    this.disconnect();
+    this.isInitialized = false;
   }
 
-  async updateViewport(viewport: Viewport, force?: boolean) {
+  async updateViewport(viewport: Viewport) {
     if (viewport.height === 0 && viewport.width === 0) {
       return;
     }
-    if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) {
+
+    if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
       return;
     }
-    await this.complete;
-    // changing width invalidates the actual height, and needs to be remeasured, since width changes causes
-    // layout reflows.
-    const changedWidth = this.viewport.width != viewport.width;
-    this.viewport = { ...viewport };
-    await this.initialLayout(changedWidth);
-  }
 
-  private async initialLayout(changedWidth: boolean) {
-    for (const bucket of this.buckets) {
-      this.updateGeometry(bucket, changedWidth);
-    }
-    this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
-
-    const loaders = [];
-    let height = 0;
-    for (const bucket of this.buckets) {
-      if (height >= this.viewport.height) {
-        break;
-      }
-      height += bucket.bucketHeight;
-      loaders.push(this.loadBucket(bucket.bucketDate));
-    }
-    await Promise.all(loaders);
-    this.notifyListeners({ type: 'viewport' });
-  }
-
-  private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
-    if (invalidateHeight) {
-      bucket.isBucketHeightActual = false;
-      bucket.measured = false;
-      for (const assetGroup of bucket.dateGroups) {
-        assetGroup.heightActual = false;
+    // special case updateViewport before or soon after call to updateOptions
+    if (!this.initTask.executed) {
+      // eslint-disable-next-line unicorn/prefer-ternary
+      if (this.initTask.loading) {
+        await this.initTask.waitUntilCompletion();
+      } else {
+        // not executed and not loaded means we should init now, and init will
+        // also update geometry so just return after
+        await this.#init(this.#options);
       }
     }
-    const viewportWidth = this.viewport.width;
-    if (!bucket.isBucketHeightActual) {
-      const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
-      const rows = Math.ceil(unwrappedWidth / viewportWidth);
-      const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT;
 
-      this.setBucketHeight(bucket, height, false);
+    // changing width affects the actual height, and needs to re-layout
+    const changedWidth = viewport.width !== this.viewportWidth;
+    this.viewportHeight = viewport.height;
+    this.viewportWidth = viewport.width;
+    this.#updateViewportGeometry(changedWidth);
+  }
+
+  #updateViewportGeometry(changedWidth: boolean) {
+    if (!this.isInitialized) {
+      return;
     }
-    const layoutOptions = {
+    if (this.viewportWidth === 0 || this.viewportHeight === 0) {
+      return;
+    }
+    for (const bucket of this.buckets) {
+      this.#updateGeometry(bucket, changedWidth);
+    }
+    this.updateIntersections();
+    this.#createScrubBuckets();
+  }
+
+  #createScrubBuckets() {
+    this.scrubberBuckets = this.buckets.map((bucket) => ({
+      assetCount: bucket.bucketCount,
+      bucketDate: bucket.bucketDate,
+      bucketDateFormattted: bucket.bucketDateFormatted,
+      bucketHeight: bucket.bucketHeight,
+    }));
+    this.scrubberTimelineHeight = this.timelineHeight;
+  }
+
+  createLayoutOptions() {
+    const viewportWidth = this.viewportWidth;
+    return {
       spacing: 2,
       heightTolerance: 0.15,
       rowHeight: 235,
       rowWidth: Math.floor(viewportWidth),
     };
-    for (const assetGroup of bucket.dateGroups) {
-      if (!assetGroup.heightActual) {
-        const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10);
-        const rows = Math.ceil(unwrappedWidth / this.viewport.width);
-        const height = rows * THUMBNAIL_HEIGHT;
-        assetGroup.height = height;
-      }
-
-      assetGroup.geometry = getJustifiedLayoutFromAssets(assetGroup.assets, layoutOptions);
+  }
+  #updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
+    if (invalidateHeight) {
+      bucket.isBucketHeightActual = false;
     }
+    if (!bucket.isLoaded) {
+      // optimize - if bucket already has data, no need to create estimates
+      const viewportWidth = this.viewportWidth;
+      if (!bucket.isBucketHeightActual) {
+        const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
+        const rows = Math.ceil(unwrappedWidth / viewportWidth);
+        const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT;
+        bucket.bucketHeight = height;
+      }
+      return;
+    }
+    this.#layoutBucket(bucket);
   }
 
-  async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise<void> {
-    const bucket = this.getBucketByDate(bucketDate);
+  #layoutBucket(bucket: AssetBucket) {
+    // these are top offsets, for each row
+    let cummulativeHeight = 0;
+    // these are left offsets of each group, for each row
+    let cummulativeWidth = 0;
+    let lastRowHeight = 0;
+    let lastRow = 0;
+
+    let dateGroupRow = 0;
+    let dateGroupCol = 0;
+
+    const rowSpaceRemaining: number[] = Array.from({ length: bucket.dateGroups.length });
+    rowSpaceRemaining.fill(this.viewportWidth, 0, bucket.dateGroups.length);
+    const options = this.createLayoutOptions();
+    for (const assetGroup of bucket.dateGroups) {
+      assetGroup.layout(options);
+      rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
+      if (dateGroupCol > 0) {
+        rowSpaceRemaining[dateGroupRow] -= GAP;
+      }
+      if (rowSpaceRemaining[dateGroupRow] >= 0) {
+        assetGroup.row = dateGroupRow;
+        assetGroup.col = dateGroupCol;
+        assetGroup.left = cummulativeWidth;
+        assetGroup.top = cummulativeHeight;
+
+        dateGroupCol++;
+
+        cummulativeWidth += assetGroup.width + GAP;
+      } else {
+        // starting a new row, we need to update the last col of the previous row
+        cummulativeWidth = 0;
+        dateGroupRow++;
+        dateGroupCol = 0;
+        assetGroup.row = dateGroupRow;
+        assetGroup.col = dateGroupCol;
+        assetGroup.left = cummulativeWidth;
+
+        rowSpaceRemaining[dateGroupRow] -= assetGroup.width;
+        dateGroupCol++;
+        cummulativeHeight += lastRowHeight;
+        assetGroup.top = cummulativeHeight;
+        cummulativeWidth += assetGroup.width + GAP;
+        lastRow = assetGroup.row - 1;
+      }
+      lastRowHeight = assetGroup.height + HEADER;
+    }
+    if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
+      cummulativeHeight += lastRowHeight;
+    }
+
+    bucket.bucketHeight = cummulativeHeight;
+    bucket.isBucketHeightActual = true;
+  }
+
+  async loadBucket(bucketDate: string, options?: { cancelable: boolean }): Promise<void> {
+    let cancelable = true;
+    if (options) {
+      cancelable = options.cancelable;
+    }
+
+    const date = DateTime.fromISO(bucketDate).toUTC();
+    const year = date.get('year');
+    const month = date.get('month');
+    const bucket = this.getBucketByDate(year, month);
     if (!bucket) {
       return;
     }
-    if (bucket.isLoaded) {
-      // already loaded
+
+    if (bucket.loader?.executed) {
       return;
     }
 
-    if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) {
-      // if promise is pending, and preventCancel is requested, then don't overwrite it
-      if (!bucket.isPreventCancel && options.preventCancel) {
-        bucket.isPreventCancel = options.preventCancel;
-      }
-      await bucket.complete;
-      return;
-    }
-
-    if (options.pending) {
-      this.pendingScrollBucket = bucket;
-    }
-    this.notifyListeners({ type: 'load', bucket });
-    bucket.isPreventCancel = !!options.preventCancel;
-    const cancelToken = (bucket.cancelToken = new AbortController());
-    try {
+    const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
       const assets = await getTimeBucket(
         {
-          ...this.options,
+          ...this.#options,
           timeBucket: bucketDate,
+          size: TimeBucketSize.Month,
           key: getKey(),
         },
-        { signal: cancelToken.signal },
+        { signal },
       );
-
-      if (cancelToken.signal.aborted) {
-        this.notifyListeners({ type: 'cancel', bucket });
-        return;
-      }
-
-      if (this.albumId) {
-        const albumAssets = await getTimeBucket(
-          {
-            albumId: this.albumId,
-            timeBucket: bucketDate,
-            size: this.options.size,
-            key: getKey(),
-          },
-          { signal: cancelToken.signal },
-        );
-        if (cancelToken.signal.aborted) {
-          this.notifyListeners({ type: 'cancel', bucket });
-          return;
+      if (assets) {
+        if (this.#options.timelineAlbumId) {
+          const albumAssets = await getTimeBucket(
+            {
+              albumId: this.#options.timelineAlbumId,
+              timeBucket: bucketDate,
+              size: TimeBucketSize.Month,
+              key: getKey(),
+            },
+            { signal },
+          );
+          for (const asset of albumAssets) {
+            this.albumAssets.add(asset.id);
+          }
         }
-        for (const asset of albumAssets) {
-          this.albumAssets.add(asset.id);
+
+        const unprocessed = bucket.addAssets(assets);
+        if (unprocessed.length > 0) {
+          console.error(
+            `Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
+          );
         }
+        this.#layoutBucket(bucket);
       }
-
-      bucket.assets = assets;
-      bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
-      this.updateGeometry(bucket, true);
-      this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
-      bucket.loaded();
-      this.notifyListeners({ type: 'loaded', bucket });
-    } catch (error) {
-      /* eslint-disable-next-line  @typescript-eslint/no-explicit-any */
-      if ((error as any).name === 'AbortError') {
-        return;
-      }
-      const _$t = get(t);
-      handleError(error, _$t('errors.failed_to_load_assets'));
-      bucket.errored();
-    } finally {
-      bucket.cancelToken = undefined;
+    }, cancelable);
+    if (result === 'LOADED') {
+      this.#updateIntersection(bucket);
     }
   }
 
-  setBucketHeight(bucket: AssetBucket, newHeight: number, isActualHeight: boolean) {
-    const delta = newHeight - bucket.bucketHeight;
-    bucket.isBucketHeightActual = isActualHeight;
-    bucket.bucketHeight = newHeight;
-    this.timelineHeight += delta;
-    this.notifyListeners({ type: 'bucket-height', bucket, delta });
-  }
-
-  updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
-    const bucket = this.getBucketByDate(bucketDate);
-    if (!bucket) {
-      return {};
-    }
-    const delta = 0;
-    if ('height' in properties) {
-      this.setBucketHeight(bucket, properties.height!, true);
-    }
-    if ('intersecting' in properties) {
-      bucket.intersecting = properties.intersecting!;
-    }
-    if ('measured' in properties) {
-      if (properties.measured) {
-        bucket.measuredSignal?.();
-      }
-      bucket.measured = properties.measured!;
-    }
-    return { delta };
-  }
-
-  updateBucketDateGroup(
-    bucket: AssetBucket,
-    dateGroup: DateGroup,
-    properties: { height?: number; intersecting?: boolean },
-  ) {
-    let delta = 0;
-    if ('height' in properties) {
-      const height = properties.height!;
-      if (height > 0) {
-        delta = height - dateGroup.height;
-        dateGroup.heightActual = true;
-        dateGroup.height = height;
-        this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height });
-      }
-    }
-    if ('intersecting' in properties) {
-      dateGroup.intersecting = properties.intersecting!;
-      if (dateGroup.intersecting) {
-        this.notifyListeners({ type: 'intersecting', bucket, dateGroup });
-      }
-    }
-    return { delta };
-  }
-
   addAssets(assets: AssetResponseDto[]) {
     const assetsToUpdate: AssetResponseDto[] = [];
-    const assetsToAdd: AssetResponseDto[] = [];
 
     for (const asset of assets) {
-      if (
-        this.assetToBucket[asset.id] ||
-        this.options.userId ||
-        this.options.personId ||
-        this.options.albumId ||
-        this.isExcluded(asset)
-      ) {
-        // If asset is already in the bucket we don't need to recalculate
-        // asset store containers
-        assetsToUpdate.push(asset);
-      } else {
-        assetsToAdd.push(asset);
+      if (this.isExcluded(asset)) {
+        continue;
       }
+      assetsToUpdate.push(asset);
     }
 
-    this.updateAssets(assetsToUpdate);
-    this.addAssetsToBuckets(assetsToAdd);
+    const notUpdated = this.updateAssets(assetsToUpdate);
+    this.#addAssetsToBuckets([...notUpdated]);
   }
 
-  private addAssetsToBuckets(assets: AssetResponseDto[]) {
+  #addAssetsToBuckets(assets: AssetResponseDto[]) {
     if (assets.length === 0) {
       return;
     }
     const updatedBuckets = new Set<AssetBucket>();
+    const updatedDateGroups = new Set<AssetDateGroup>();
 
     for (const asset of assets) {
-      const timeBucket = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month').toString();
-      let bucket = this.getBucketByDate(timeBucket);
+      const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
+      const year = utc.get('year');
+      const month = utc.get('month');
+      let bucket = this.getBucketByDate(year, month);
 
       if (!bucket) {
-        bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT });
+        bucket = new AssetBucket(this, utc, 1);
         this.buckets.push(bucket);
       }
-
-      bucket.assets.push(asset);
+      bucket.addAssets([asset]);
       updatedBuckets.add(bucket);
     }
 
-    this.buckets = this.buckets.sort((a, b) => {
-      const aDate = DateTime.fromISO(a.bucketDate).toUTC();
-      const bDate = DateTime.fromISO(b.bucketDate).toUTC();
-      return bDate.diff(aDate).milliseconds;
+    this.buckets.sort((a, b) => {
+      return a.year === b.year ? b.month - a.month : b.year - a.year;
     });
 
-    for (const bucket of updatedBuckets) {
-      bucket.assets.sort((a, b) => {
-        const aDate = DateTime.fromISO(a.fileCreatedAt).toUTC();
-        const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC();
-        return bDate.diff(aDate).milliseconds;
-      });
-      bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
-      this.updateGeometry(bucket, true);
+    for (const dateGroup of updatedDateGroups) {
+      dateGroup.sortAssets();
     }
+    for (const bucket of updatedBuckets) {
+      bucket.sortDateGroups();
+      this.#updateGeometry(bucket, true);
+    }
+    this.updateIntersections();
   }
 
-  getBucketByDate(bucketDate: string): AssetBucket | null {
-    return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
+  getBucketByDate(year: number, month: number): AssetBucket | undefined {
+    return this.buckets.find((bucket) => bucket.year === year && bucket.month === month);
   }
 
-  async findAndLoadBucketAsPending(id: string) {
-    const bucketInfo = this.assetToBucket[id];
-    let bucket: AssetBucket | null = bucketInfo?.bucket ?? null;
+  async findBucketForAsset(id: string) {
+    await this.initTask.waitUntilCompletion();
+    let bucket = this.#findBucketForAsset(id);
     if (!bucket) {
       const asset = await getAssetInfo({ id });
       if (!asset || this.isExcluded(asset)) {
         return;
       }
-      bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
+      bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false });
     }
 
-    if (bucket && bucket.assets.some((a) => a.id === id)) {
-      this.pendingScrollBucket = bucket;
-      this.pendingScrollAssetId = id;
+    if (bucket && bucket?.containsAssetId(id)) {
       return bucket;
     }
   }
 
-  /* Must be paired with matching clearPendingScroll() call */
-  async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) {
-    try {
-      const { at: assetId } = scrollTarget;
-      if (assetId) {
-        await this.complete;
-        const bucket = await this.findAndLoadBucketAsPending(assetId);
-        if (bucket) {
-          return;
-        }
-      }
-    } catch {
-      // failure
-    }
-    onFailure();
-  }
-
-  clearPendingScroll() {
-    this.pendingScrollBucket = undefined;
-    this.pendingScrollAssetId = undefined;
-  }
-
-  private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) {
+  async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) {
     let date = fromLocalDateTime(localDateTime);
-    if (this.options.size == TimeBucketSize.Month) {
-      date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
-    } else if (this.options.size == TimeBucketSize.Day) {
-      date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
-    }
+    // Only support TimeBucketSize.Month
+    date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
     const iso = date.toISO()!;
+    const year = date.get('year');
+    const month = date.get('month');
     await this.loadBucket(iso, options);
-    return this.getBucketByDate(iso);
+    return this.getBucketByDate(year, month);
   }
 
-  private async getBucketInfoForAsset(
-    { id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>,
-    options: { preventCancel?: boolean; pending?: boolean } = {},
-  ) {
-    const bucketInfo = this.assetToBucket[id];
+  async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) {
+    const bucketInfo = this.#findBucketForAsset(asset.id);
     if (bucketInfo) {
       return bucketInfo;
     }
-    await this.loadBucketAtTime(localDateTime, options);
-    return this.assetToBucket[id] || null;
+    await this.#loadBucketAtTime(asset.localDateTime, options);
+    return this.#findBucketForAsset(asset.id);
   }
 
   getBucketIndexByAssetId(assetId: string) {
-    return this.assetToBucket[assetId]?.bucketIndex ?? null;
+    return this.#findBucketForAsset(assetId);
   }
 
-  async getRandomAsset(): Promise<AssetResponseDto | null> {
-    let index = Math.floor(
-      Math.random() * this.buckets.reduce((accumulator, bucket) => accumulator + bucket.bucketCount, 0),
-    );
-    for (const bucket of this.buckets) {
-      if (index < bucket.bucketCount) {
-        await this.loadBucket(bucket.bucketDate);
-        return bucket.assets[index] || null;
-      }
+  async getRandomBucket() {
+    const random = Math.floor(Math.random() * this.buckets.length);
+    const bucket = this.buckets[random];
+    await this.loadBucket(bucket.bucketDate, { cancelable: false });
+    return bucket;
+  }
 
-      index -= bucket.bucketCount;
+  async getRandomAsset() {
+    const bucket = await this.getRandomBucket();
+    return bucket?.getRandomAsset();
+  }
+
+  // runs op on assets, returns unprocessed
+  #runAssetOperation(ids: Set<string>, operation: AssetOperation) {
+    if (ids.size === 0) {
+      return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
     }
 
-    return null;
+    const changedBuckets = new Set<AssetBucket>();
+    let idsToProcess = new Set(ids);
+    const idsProcessed = new Set<string>();
+    const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = [];
+    for (const bucket of this.buckets) {
+      if (idsToProcess.size > 0) {
+        const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
+        if (moveAssets.length > 0) {
+          combinedMoveAssets.push(moveAssets);
+        }
+        idsToProcess = idsToProcess.difference(processedIds);
+        for (const id of processedIds) {
+          idsProcessed.add(id);
+        }
+        if (changedGeometry) {
+          changedBuckets.add(bucket);
+          break;
+        }
+      }
+    }
+    if (combinedMoveAssets.length > 0) {
+      this.#addAssetsToBuckets(combinedMoveAssets.flat().map((a) => a.asset));
+    }
+    const changedGeometry = changedBuckets.size > 0;
+    for (const bucket of changedBuckets) {
+      this.#updateGeometry(bucket, true);
+    }
+    if (changedGeometry) {
+      this.updateIntersections();
+    }
+    return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
+  }
+
+  /**
+   * Runs a callback on a list of asset ids. The assets in the AssetStore are reactive -
+   * any change to the asset (i.e. changing isFavorite, isArchived, etc) will automatically
+   * cause the UI to update with no further actions needed. Changing the date of an asset
+   * will automatically move it to another bucket if needed. Removing the asset will remove
+   * it from any view that is showing it.
+   *
+   * @param ids to run the operation on
+   * @param operation callback to update the specified asset ids
+   */
+  updateAssetOperation(ids: string[], operation: AssetOperation) {
+    this.#runAssetOperation(new Set(ids), operation);
   }
 
   updateAssets(assets: AssetResponseDto[]) {
-    if (assets.length === 0) {
-      return;
-    }
-    const assetsToRecalculate: AssetResponseDto[] = [];
-
-    for (const _asset of assets) {
-      const asset = this.assets.find((asset) => asset.id === _asset.id);
-      if (!asset) {
-        continue;
-      }
-
-      const recalculate = asset.localDateTime !== _asset.localDateTime;
-      Object.assign(asset, _asset);
-
-      if (recalculate) {
-        assetsToRecalculate.push(asset);
-      }
-    }
-
-    this.removeAssets(assetsToRecalculate.map((asset) => asset.id));
-    this.addAssetsToBuckets(assetsToRecalculate);
+    const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset]));
+    const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
+      updateObject(asset, lookup.get(asset.id));
+      return { remove: false };
+    });
+    return unprocessedIds.values().map((id) => lookup.get(id)!);
   }
 
   removeAssets(ids: string[]) {
-    const idSet = new Set(ids);
+    const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => {
+      return { remove: true };
+    });
+    return [...unprocessedIds];
+  }
 
-    // Iterate in reverse to allow array splicing.
-    for (let index = this.buckets.length - 1; index >= 0; index--) {
-      const bucket = this.buckets[index];
-      let changed = false;
-      for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) {
-        const asset = bucket.assets[index_];
-        if (!idSet.has(asset.id)) {
-          continue;
-        }
+  refreshLayout() {
+    for (const bucket of this.buckets) {
+      this.#updateGeometry(bucket, true);
+    }
+    this.updateIntersections();
+  }
 
-        bucket.assets.splice(index_, 1);
-        changed = true;
-        if (bucket.assets.length === 0) {
-          this.buckets.splice(index, 1);
-        }
-      }
-      if (changed) {
-        bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
-        this.updateGeometry(bucket, true);
+  getFirstAsset(): AssetResponseDto | undefined {
+    return this.buckets[0]?.getFirstAsset();
+  }
+
+  async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
+    let bucket = await this.#getBucketInfoForAsset(asset);
+    if (!bucket) {
+      return;
+    }
+
+    for (const group of bucket.dateGroups) {
+      const index = group.intersetingAssets.findIndex((ia) => ia.id === asset.id);
+      if (index > 0) {
+        return group.intersetingAssets[index - 1].asset;
       }
     }
+
+    let bucketIndex = this.buckets.indexOf(bucket) - 1;
+    while (bucketIndex >= 0) {
+      bucket = this.buckets[bucketIndex];
+      if (!bucket) {
+        return;
+      }
+      await this.loadBucket(bucket.bucketDate);
+      const previous = bucket.lastDateGroup?.intersetingAssets.at(-1)?.asset;
+      if (previous) {
+        return previous;
+      }
+      bucketIndex--;
+    }
   }
 
-  getFirstAsset(): AssetResponseDto | null {
-    if (this.buckets.length > 0 && this.buckets[0].assets.length > 0) {
-      return this.buckets[0].assets[0];
+  async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
+    let bucket = await this.#getBucketInfoForAsset(asset);
+    if (!bucket) {
+      return;
+    }
+
+    for (const group of bucket.dateGroups) {
+      const index = group.intersetingAssets.findIndex((ia) => ia.id === asset.id);
+      if (index !== -1 && index < group.intersetingAssets.length - 1) {
+        return group.intersetingAssets[index + 1].asset;
+      }
+    }
+
+    let bucketIndex = this.buckets.indexOf(bucket) + 1;
+    while (bucketIndex < this.buckets.length - 1) {
+      bucket = this.buckets[bucketIndex];
+      await this.loadBucket(bucket.bucketDate);
+      const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset;
+      if (next) {
+        return next;
+      }
+      bucketIndex++;
     }
-    return null;
   }
 
-  async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
-    const info = await this.getBucketInfoForAsset(asset);
-    if (!info) {
-      return null;
-    }
-
-    const { bucket, assetIndex, bucketIndex } = info;
-
-    if (assetIndex !== 0) {
-      return bucket.assets[assetIndex - 1];
-    }
-
-    if (bucketIndex === 0) {
-      return null;
-    }
-
-    const previousBucket = this.buckets[bucketIndex - 1];
-    await this.loadBucket(previousBucket.bucketDate);
-    return previousBucket.assets.at(-1) || null;
-  }
-
-  async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
-    const info = await this.getBucketInfoForAsset(asset);
-    if (!info) {
-      return null;
-    }
-
-    const { bucket, assetIndex, bucketIndex } = info;
-
-    if (assetIndex !== bucket.assets.length - 1) {
-      return bucket.assets[assetIndex + 1];
-    }
-
-    if (bucketIndex === this.buckets.length - 1) {
-      return null;
-    }
-
-    const nextBucket = this.buckets[bucketIndex + 1];
-    await this.loadBucket(nextBucket.bucketDate);
-    return nextBucket.assets[0] || null;
-  }
-
-  private isExcluded(asset: AssetResponseDto) {
+  isExcluded(asset: AssetResponseDto) {
     return (
-      isMismatched(this.options.isArchived ?? false, asset.isArchived) ||
-      isMismatched(this.options.isFavorite, asset.isFavorite) ||
-      isMismatched(this.options.isTrashed ?? false, asset.isTrashed)
+      isMismatched(this.#options.isArchived, asset.isArchived) ||
+      isMismatched(this.#options.isFavorite, asset.isFavorite) ||
+      isMismatched(this.#options.isTrashed, asset.isTrashed)
     );
   }
 }
diff --git a/web/src/lib/stores/timeline.store.ts b/web/src/lib/stores/timeline.store.ts
deleted file mode 100644
index b86a171765..0000000000
--- a/web/src/lib/stores/timeline.store.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { writable } from 'svelte/store';
-
-export const isTimelineScrolling = writable(false);
diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts
deleted file mode 100644
index e9a9e459fe..0000000000
--- a/web/src/lib/utils/asset-store-task-manager.ts
+++ /dev/null
@@ -1,465 +0,0 @@
-import type { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
-import { generateId } from '$lib/utils/generate-id';
-import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
-import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
-import { type DateGroup } from '$lib/utils/timeline-util';
-import { TUNABLES } from '$lib/utils/tunables';
-import { type AssetResponseDto } from '@immich/sdk';
-import { clamp } from 'lodash-es';
-
-type Task = () => void;
-
-class InternalTaskManager {
-  assetStore: AssetStore;
-  componentTasks = new Map<string, Set<string>>();
-  priorityQueue = new KeyedPriorityQueue<string, Task>();
-  idleQueue = new Map<string, Task>();
-  taskCleaners = new Map<string, Task>();
-
-  queueTimer: ReturnType<typeof setTimeout> | undefined;
-  lastIdle: number | undefined;
-
-  constructor(assetStore: AssetStore) {
-    this.assetStore = assetStore;
-  }
-  destroy() {
-    this.componentTasks.clear();
-    this.priorityQueue.clear();
-    this.idleQueue.clear();
-    this.taskCleaners.clear();
-    clearTimeout(this.queueTimer);
-    if (this.lastIdle) {
-      cancelIdleCB(this.lastIdle);
-    }
-  }
-  getOrCreateComponentTasks(componentId: string) {
-    let componentTaskSet = this.componentTasks.get(componentId);
-    if (!componentTaskSet) {
-      componentTaskSet = new Set<string>();
-      this.componentTasks.set(componentId, componentTaskSet);
-    }
-
-    return componentTaskSet;
-  }
-  deleteFromComponentTasks(componentId: string, taskId: string) {
-    if (this.componentTasks.has(componentId)) {
-      const componentTaskSet = this.componentTasks.get(componentId);
-      componentTaskSet?.delete(taskId);
-      if (componentTaskSet?.size === 0) {
-        this.componentTasks.delete(componentId);
-      }
-    }
-  }
-
-  drainIntersectedQueue() {
-    let count = 0;
-    for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) {
-      t.value();
-      if (this.taskCleaners.has(t.key)) {
-        this.taskCleaners.get(t.key)!();
-        this.taskCleaners.delete(t.key);
-      }
-      if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
-        this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS);
-        break;
-      }
-    }
-  }
-
-  scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) {
-    clearTimeout(this.queueTimer);
-    this.queueTimer = setTimeout(() => {
-      const delta = Date.now() - this.assetStore.lastScrollTime;
-      if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
-        let amount = clamp(
-          1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR),
-          1,
-          TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2,
-        );
-
-        const nextDelay = clamp(
-          amount > 1
-            ? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR)
-            : TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS,
-          TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY,
-          TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY,
-        );
-
-        while (amount > 0) {
-          this.priorityQueue.shift()?.value();
-          amount--;
-        }
-        if (this.priorityQueue.length > 0) {
-          this.scheduleDrainIntersectedQueue(nextDelay);
-        }
-      } else {
-        this.drainIntersectedQueue();
-      }
-    }, delay);
-  }
-
-  removeAllTasksForComponent(componentId: string) {
-    if (this.componentTasks.has(componentId)) {
-      const tasksIds = this.componentTasks.get(componentId) || [];
-      for (const taskId of tasksIds) {
-        this.priorityQueue.remove(taskId);
-        this.idleQueue.delete(taskId);
-        if (this.taskCleaners.has(taskId)) {
-          const cleanup = this.taskCleaners.get(taskId);
-          this.taskCleaners.delete(taskId);
-          cleanup!();
-        }
-      }
-    }
-    this.componentTasks.delete(componentId);
-  }
-
-  queueScrollSensitiveTask({
-    task,
-    cleanup,
-    componentId,
-    priority = 10,
-    taskId = generateId(),
-  }: {
-    task: Task;
-    cleanup?: Task;
-    componentId: string;
-    priority?: number;
-    taskId?: string;
-  }) {
-    this.priorityQueue.push(taskId, task, priority);
-    if (cleanup) {
-      this.taskCleaners.set(taskId, cleanup);
-    }
-    this.getOrCreateComponentTasks(componentId).add(taskId);
-    const lastTime = this.assetStore.lastScrollTime;
-    const delta = Date.now() - lastTime;
-    if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
-      this.scheduleDrainIntersectedQueue();
-    } else {
-      // flush the queue early
-      clearTimeout(this.queueTimer);
-      this.drainIntersectedQueue();
-    }
-  }
-
-  scheduleDrainSeparatedQueue() {
-    if (this.lastIdle) {
-      cancelIdleCB(this.lastIdle);
-    }
-    this.lastIdle = idleCB(
-      () => {
-        let count = 0;
-        let entry = this.idleQueue.entries().next().value;
-        while (entry) {
-          const [taskId, task] = entry;
-          this.idleQueue.delete(taskId);
-          task();
-          if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
-            break;
-          }
-          entry = this.idleQueue.entries().next().value;
-        }
-        if (this.idleQueue.size > 0) {
-          this.scheduleDrainSeparatedQueue();
-        }
-      },
-      { timeout: 1000 },
-    );
-  }
-  queueSeparateTask({
-    task,
-    cleanup,
-    componentId,
-    taskId,
-  }: {
-    task: Task;
-    cleanup: Task;
-    componentId: string;
-    taskId: string;
-  }) {
-    this.idleQueue.set(taskId, task);
-    this.taskCleaners.set(taskId, cleanup);
-    this.getOrCreateComponentTasks(componentId).add(taskId);
-    this.scheduleDrainSeparatedQueue();
-  }
-
-  removeIntersectedTask(taskId: string) {
-    const removed = this.priorityQueue.remove(taskId);
-    if (this.taskCleaners.has(taskId)) {
-      const cleanup = this.taskCleaners.get(taskId);
-      this.taskCleaners.delete(taskId);
-      cleanup!();
-    }
-    return removed;
-  }
-
-  removeSeparateTask(taskId: string) {
-    const removed = this.idleQueue.delete(taskId);
-    if (this.taskCleaners.has(taskId)) {
-      const cleanup = this.taskCleaners.get(taskId);
-      this.taskCleaners.delete(taskId);
-      cleanup!();
-    }
-    return removed;
-  }
-}
-
-export class AssetGridTaskManager {
-  private internalManager: InternalTaskManager;
-  constructor(assetStore: AssetStore) {
-    this.internalManager = new InternalTaskManager(assetStore);
-  }
-
-  tasks: Map<AssetBucket, BucketTask> = new Map();
-
-  queueScrollSensitiveTask({
-    task,
-    cleanup,
-    componentId,
-    priority = 10,
-    taskId = generateId(),
-  }: {
-    task: Task;
-    cleanup?: Task;
-    componentId: string;
-    priority?: number;
-    taskId?: string;
-  }) {
-    return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId });
-  }
-
-  removeAllTasksForComponent(componentId: string) {
-    return this.internalManager.removeAllTasksForComponent(componentId);
-  }
-
-  destroy() {
-    return this.internalManager.destroy();
-  }
-
-  private getOrCreateBucketTask(bucket: AssetBucket) {
-    let bucketTask = this.tasks.get(bucket);
-    if (!bucketTask) {
-      bucketTask = this.createBucketTask(bucket);
-    }
-    return bucketTask;
-  }
-
-  private createBucketTask(bucket: AssetBucket) {
-    const bucketTask = new BucketTask(this.internalManager, this, bucket);
-    this.tasks.set(bucket, bucketTask);
-    return bucketTask;
-  }
-
-  intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) {
-    const bucketTask = this.getOrCreateBucketTask(bucket);
-    bucketTask.scheduleIntersected(componentId, task);
-  }
-
-  separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
-    const bucketTask = this.getOrCreateBucketTask(bucket);
-    bucketTask.scheduleSeparated(componentId, separated);
-  }
-
-  intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
-    const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
-    bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
-  }
-
-  separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
-    const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
-    bucketTask.separatedDateGroup(componentId, dateGroup, separated);
-  }
-
-  intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
-    const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
-    const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
-    dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
-  }
-
-  separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) {
-    const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
-    const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
-    dateGroupTask.separatedThumbnail(componentId, asset, separated);
-  }
-}
-
-class IntersectionTask {
-  internalTaskManager: InternalTaskManager;
-  separatedKey;
-  intersectedKey;
-  priority;
-
-  intersected: Task | undefined;
-  separated: Task | undefined;
-
-  constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
-    this.internalTaskManager = internalTaskManager;
-    this.separatedKey = keyPrefix + ':s:' + key;
-    this.intersectedKey = keyPrefix + ':i:' + key;
-    this.priority = priority;
-  }
-
-  trackIntersectedTask(componentId: string, task: Task) {
-    const execTask = () => {
-      if (this.separated) {
-        return;
-      }
-      task?.();
-    };
-    this.intersected = execTask;
-    const cleanup = () => {
-      this.intersected = undefined;
-      this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey);
-    };
-    return { task: execTask, cleanup };
-  }
-
-  trackSeparatedTask(componentId: string, task: Task) {
-    const execTask = () => {
-      if (this.intersected) {
-        return;
-      }
-      task?.();
-    };
-    this.separated = execTask;
-    const cleanup = () => {
-      this.separated = undefined;
-      this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
-    };
-    return { task: execTask, cleanup };
-  }
-
-  removePendingSeparated() {
-    if (this.separated) {
-      this.internalTaskManager.removeSeparateTask(this.separatedKey);
-    }
-  }
-  removePendingIntersected() {
-    if (this.intersected) {
-      this.internalTaskManager.removeIntersectedTask(this.intersectedKey);
-    }
-  }
-
-  scheduleIntersected(componentId: string, intersected: Task) {
-    this.removePendingSeparated();
-    if (this.intersected) {
-      return;
-    }
-    const { task, cleanup } = this.trackIntersectedTask(componentId, intersected);
-    this.internalTaskManager.queueScrollSensitiveTask({
-      task,
-      cleanup,
-      componentId,
-      priority: this.priority,
-      taskId: this.intersectedKey,
-    });
-  }
-
-  scheduleSeparated(componentId: string, separated: Task) {
-    this.removePendingIntersected();
-
-    if (this.separated) {
-      return;
-    }
-
-    const { task, cleanup } = this.trackSeparatedTask(componentId, separated);
-    this.internalTaskManager.queueSeparateTask({
-      task,
-      cleanup,
-      componentId,
-      taskId: this.separatedKey,
-    });
-  }
-}
-class BucketTask extends IntersectionTask {
-  assetBucket: AssetBucket;
-  assetGridTaskManager: AssetGridTaskManager;
-  // indexed by dateGroup's date
-  dateTasks: Map<DateGroup, DateGroupTask> = new Map();
-
-  constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) {
-    super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY);
-    this.assetBucket = assetBucket;
-    this.assetGridTaskManager = parent;
-  }
-
-  getOrCreateDateGroupTask(dateGroup: DateGroup) {
-    let dateGroupTask = this.dateTasks.get(dateGroup);
-    if (!dateGroupTask) {
-      dateGroupTask = this.createDateGroupTask(dateGroup);
-    }
-    return dateGroupTask;
-  }
-
-  createDateGroupTask(dateGroup: DateGroup) {
-    const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup);
-    this.dateTasks.set(dateGroup, dateGroupTask);
-    return dateGroupTask;
-  }
-
-  removePendingSeparated() {
-    super.removePendingSeparated();
-    for (const dateGroupTask of this.dateTasks.values()) {
-      dateGroupTask.removePendingSeparated();
-    }
-  }
-
-  intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
-    const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
-    dateGroupTask.scheduleIntersected(componentId, intersected);
-  }
-
-  separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
-    const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
-    dateGroupTask.scheduleSeparated(componentId, separated);
-  }
-}
-class DateGroupTask extends IntersectionTask {
-  dateGroup: DateGroup;
-  bucketTask: BucketTask;
-  // indexed by thumbnail's asset
-  thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map();
-
-  constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) {
-    super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY);
-    this.dateGroup = dateGroup;
-    this.bucketTask = parent;
-  }
-
-  removePendingSeparated() {
-    super.removePendingSeparated();
-    for (const thumbnailTask of this.thumbnailTasks.values()) {
-      thumbnailTask.removePendingSeparated();
-    }
-  }
-
-  getOrCreateThumbnailTask(asset: AssetResponseDto) {
-    let thumbnailTask = this.thumbnailTasks.get(asset);
-    if (!thumbnailTask) {
-      thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset);
-      this.thumbnailTasks.set(asset, thumbnailTask);
-    }
-    return thumbnailTask;
-  }
-
-  intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) {
-    const thumbnailTask = this.getOrCreateThumbnailTask(asset);
-    thumbnailTask.scheduleIntersected(componentId, intersected);
-  }
-
-  separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
-    const thumbnailTask = this.getOrCreateThumbnailTask(asset);
-    thumbnailTask.scheduleSeparated(componentId, separated);
-  }
-}
-class ThumbnailTask extends IntersectionTask {
-  asset: AssetResponseDto;
-  dateGroupTask: DateGroupTask;
-
-  constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) {
-    super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY);
-    this.asset = asset;
-    this.dateGroupTask = parent;
-  }
-}
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts
index b802bbe0a3..82112b6dbf 100644
--- a/web/src/lib/utils/asset-utils.ts
+++ b/web/src/lib/utils/asset-utils.ts
@@ -476,7 +476,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
       if (!get(isSelectingAllAssets)) {
         break; // Cancelled
       }
-      assetInteraction.selectAssets(bucket.assets);
+      assetInteraction.selectAssets(bucket.getAssets().map((a) => $state.snapshot(a)));
 
       // We use setTimeout to allow the UI to update. Otherwise, this may
       // cause a long delay between the start of 'select all' and the
diff --git a/web/src/lib/utils/cancellable-task.ts b/web/src/lib/utils/cancellable-task.ts
new file mode 100644
index 0000000000..412639af4b
--- /dev/null
+++ b/web/src/lib/utils/cancellable-task.ts
@@ -0,0 +1,135 @@
+export class CancellableTask {
+  cancelToken: AbortController | null = null;
+  cancellable: boolean = true;
+  /**
+   * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
+   */
+  complete!: Promise<unknown>;
+  executed: boolean = false;
+
+  private loadedSignal: (() => void) | undefined;
+  private canceledSignal: (() => void) | undefined;
+
+  constructor(
+    private loadedCallback?: () => void,
+    private canceledCallback?: () => void,
+    private errorCallback?: (error: unknown) => void,
+  ) {
+    this.complete = new Promise<void>((resolve, reject) => {
+      this.loadedSignal = resolve;
+      this.canceledSignal = reject;
+    }).catch(
+      () =>
+        // if no-one waits on complete its rejected a uncaught rejection message is logged.
+        // prevent this message with an empty reject handler, since waiting on a bucket is optional.
+        void 0,
+    );
+  }
+
+  get loading() {
+    return !!this.cancelToken;
+  }
+
+  async waitUntilCompletion() {
+    if (this.executed) {
+      return 'DONE';
+    }
+    // if there is a cancel token, task is currently executing, so wait on the promise. If it
+    // isn't, then  the task is in new state, it hasn't been loaded, nor has it been executed.
+    // in either case, we wait on the promise.
+    await this.complete;
+    return 'WAITED';
+  }
+
+  async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
+    if (this.executed) {
+      return 'DONE';
+    }
+
+    // if promise is pending, wait on previous request instead.
+    if (this.cancelToken) {
+      // if promise is pending, and preventCancel is requested,
+      // do not allow transition from prevent cancel to allow cancel.
+      if (this.cancellable && !cancellable) {
+        this.cancellable = cancellable;
+      }
+      await this.complete;
+      return 'WAITED';
+    }
+    this.cancellable = cancellable;
+    const cancelToken = (this.cancelToken = new AbortController());
+
+    try {
+      await f(cancelToken.signal);
+      this.#transitionToExecuted();
+      return 'LOADED';
+    } catch (error) {
+      // eslint-disable-next-line  @typescript-eslint/no-explicit-any
+      if ((error as any).name === 'AbortError') {
+        // abort error is not treated as an error, but as a cancelation.
+        return 'CANCELED';
+      }
+      this.#transitionToErrored(error);
+      return 'ERRORED';
+    } finally {
+      this.cancelToken = null;
+    }
+  }
+
+  private init() {
+    this.cancelToken = null;
+    this.executed = false;
+    // create a promise, and store its resolve/reject callbacks. The loadedSignal callback
+    // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
+    // callback will be called if the bucket is canceled before it was loaded, rejecting the
+    // promise.
+    this.complete = new Promise<void>((resolve, reject) => {
+      this.loadedSignal = resolve;
+      this.canceledSignal = reject;
+    }).catch(
+      () =>
+        // if no-one waits on complete its rejected a uncaught rejection message is logged.
+        // prevent this message with an empty reject handler, since waiting on a bucket is optional.
+        void 0,
+    );
+  }
+
+  // will reset this job back to the initial state (isLoaded=false, no errors, etc)
+  async reset() {
+    this.#transitionToCancelled();
+    if (this.cancelToken) {
+      await this.waitUntilCompletion();
+    }
+    this.init();
+  }
+
+  cancel() {
+    this.#transitionToCancelled();
+  }
+
+  #transitionToCancelled() {
+    if (this.executed) {
+      return;
+    }
+    if (!this.cancellable) {
+      return;
+    }
+    this.cancelToken?.abort();
+    this.canceledSignal?.();
+    this.init();
+    this.canceledCallback?.();
+  }
+
+  #transitionToExecuted() {
+    this.executed = true;
+    this.loadedSignal?.();
+    this.loadedCallback?.();
+  }
+
+  #transitionToErrored(error: unknown) {
+    this.cancelToken = null;
+    this.canceledSignal?.();
+    this.init();
+    this.errorCallback?.(error);
+  }
+}
diff --git a/web/src/lib/utils/idle-callback-support.ts b/web/src/lib/utils/idle-callback-support.ts
deleted file mode 100644
index dde595379f..0000000000
--- a/web/src/lib/utils/idle-callback-support.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-interface RequestIdleCallback {
-  didTimeout?: boolean;
-  timeRemaining?(): DOMHighResTimeStamp;
-}
-interface RequestIdleCallbackOptions {
-  timeout?: number;
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) {
-  const start = Date.now();
-  return setTimeout(() => {
-    cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) });
-  }, 100);
-}
-
-function fake_cancelIdleCallback(id: number) {
-  return clearTimeout(id);
-}
-
-export const idleCB = globalThis.requestIdleCallback || fake_requestIdleCallback;
-export const cancelIdleCB = globalThis.cancelIdleCallback || fake_cancelIdleCallback;
diff --git a/web/src/lib/utils/keyed-priority-queue.ts b/web/src/lib/utils/keyed-priority-queue.ts
deleted file mode 100644
index b78ecdf353..0000000000
--- a/web/src/lib/utils/keyed-priority-queue.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-export class KeyedPriorityQueue<K, T> {
-  private items: { key: K; value: T; priority: number }[] = [];
-  private set: Set<K> = new Set();
-
-  clear() {
-    this.items = [];
-    this.set.clear();
-  }
-
-  remove(key: K) {
-    const removed = this.set.delete(key);
-    if (removed) {
-      const idx = this.items.findIndex((i) => i.key === key);
-      if (idx !== -1) {
-        this.items.splice(idx, 1);
-      }
-    }
-    return removed;
-  }
-
-  push(key: K, value: T, priority: number) {
-    if (this.set.has(key)) {
-      return this.length;
-    }
-    for (let i = 0; i < this.items.length; i++) {
-      if (this.items[i].priority > priority) {
-        this.set.add(key);
-        this.items.splice(i, 0, { key, value, priority });
-        return this.length;
-      }
-    }
-    this.set.add(key);
-    return this.items.push({ key, value, priority });
-  }
-
-  shift() {
-    let item = this.items.shift();
-    while (item) {
-      if (this.set.has(item.key)) {
-        this.set.delete(item.key);
-        return item;
-      }
-      item = this.items.shift();
-    }
-  }
-
-  get length() {
-    return this.set.size;
-  }
-}
diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts
index 6f14ed9825..d55976cb4f 100644
--- a/web/src/lib/utils/layout-utils.ts
+++ b/web/src/lib/utils/layout-utils.ts
@@ -49,18 +49,21 @@ export function getJustifiedLayoutFromAssets(
 type Geometry = ReturnType<typeof createJustifiedLayout>;
 class Adapter {
   result;
+  width;
   constructor(result: Geometry) {
     this.result = result;
+    this.width = 0;
+    for (const box of this.result.boxes) {
+      if (box.top < 100) {
+        this.width = box.left + box.width;
+      } else {
+        break;
+      }
+    }
   }
 
   get containerWidth() {
-    let width = 0;
-    for (const box of this.result.boxes) {
-      if (box.top < 100) {
-        width = box.left + box.width;
-      }
-    }
-    return width;
+    return this.width;
   }
 
   get containerHeight() {
@@ -84,12 +87,6 @@ class Adapter {
   }
 }
 
-export const emptyGeometry = new Adapter({
-  containerHeight: 0,
-  widowCount: 0,
-  boxes: [],
-});
-
 export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
   const adapter = {
     targetRowHeight: options.rowHeight,
@@ -104,3 +101,26 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
   );
   return new Adapter(result);
 }
+
+export const emptyGeometry = () =>
+  new Adapter({
+    containerHeight: 0,
+    widowCount: 0,
+    boxes: [],
+  });
+
+export type CommonPosition = {
+  top: number;
+  left: number;
+  width: number;
+  height: number;
+};
+
+export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition {
+  const top = geometry.getTop(boxIdx);
+  const left = geometry.getLeft(boxIdx);
+  const width = geometry.getWidth(boxIdx);
+  const height = geometry.getHeight(boxIdx);
+
+  return { top, left, width, height };
+}
diff --git a/web/src/lib/utils/priority-queue.ts b/web/src/lib/utils/priority-queue.ts
deleted file mode 100644
index 6b08ffe7ad..0000000000
--- a/web/src/lib/utils/priority-queue.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export class PriorityQueue<T> {
-  private items: { value: T; priority: number }[] = [];
-
-  push(value: T, priority: number) {
-    for (let i = 0; i < this.items.length; i++) {
-      if (this.items[i].priority > priority) {
-        this.items.splice(i, 0, { value, priority });
-        return this.length;
-      }
-    }
-    return this.items.push({ value, priority });
-  }
-
-  shift() {
-    return this.items.shift();
-  }
-
-  get length() {
-    return this.items.length;
-  }
-}
diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts
index d017fb0c9e..f40e2bc3eb 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -1,21 +1,24 @@
 import type { AssetBucket } from '$lib/stores/assets-store.svelte';
 import { locale } from '$lib/stores/preferences.store';
-import { emptyGeometry, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
+import { type CommonJustifiedLayout } from '$lib/utils/layout-utils';
 
 import type { AssetResponseDto } from '@immich/sdk';
-import { groupBy, memoize, sortBy } from 'lodash-es';
+import { memoize } from 'lodash-es';
 import { DateTime, type LocaleOptions } from 'luxon';
 import { get } from 'svelte/store';
 
 export type DateGroup = {
+  bucket: AssetBucket;
+  index: number;
+  row: number;
+  col: number;
   date: DateTime;
   groupTitle: string;
   assets: AssetResponseDto[];
+  assetsIntersecting: boolean[];
   height: number;
-  heightActual: boolean;
   intersecting: boolean;
   geometry: CommonJustifiedLayout;
-  bucket: AssetBucket;
 };
 export type ScrubberListener = (
   bucketDate: string | undefined,
@@ -40,6 +43,31 @@ export const fromLocalDateTime = (localDateTime: string) =>
 export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
   DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
 
+export type LayoutBox = {
+  aspectRatio: number;
+  top: number;
+  width: number;
+  height: number;
+  left: number;
+  forcedAspectRatio?: boolean;
+};
+
+export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
+  let offset = 0;
+  while (element.offsetParent && element !== stop) {
+    offset += element.offsetTop;
+    element = element.offsetParent as HTMLElement;
+  }
+  return offset;
+}
+
+export const groupDateFormat: Intl.DateTimeFormatOptions = {
+  weekday: 'short',
+  month: 'short',
+  day: 'numeric',
+  year: 'numeric',
+};
+
 export function formatGroupTitle(_date: DateTime): string {
   if (!_date.isValid) {
     return _date.toString();
@@ -73,56 +101,7 @@ export function formatGroupTitle(_date: DateTime): string {
 
   return getDateLocaleString(date);
 }
-
 export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
   date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
 
-const formatDateGroupTitle = memoize(formatGroupTitle);
-
-export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
-  const grouped = groupBy(bucket.assets, (asset) =>
-    getDateLocaleString(fromLocalDateTime(asset.localDateTime), { locale }),
-  );
-  const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0]));
-  return sorted.map((group) => {
-    const date = fromLocalDateTime(group[0].localDateTime).startOf('day');
-    return {
-      date,
-      groupTitle: formatDateGroupTitle(date),
-      assets: group,
-      height: 0,
-      heightActual: false,
-      intersecting: false,
-      geometry: emptyGeometry,
-      bucket,
-    };
-  });
-}
-
-export type LayoutBox = {
-  aspectRatio: number;
-  top: number;
-  width: number;
-  height: number;
-  left: number;
-  forcedAspectRatio?: boolean;
-};
-
-export function calculateWidth(boxes: LayoutBox[]): number {
-  let width = 0;
-  for (const box of boxes) {
-    if (box.top < 100) {
-      width = box.left + box.width;
-    }
-  }
-  return width;
-}
-
-export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
-  let offset = 0;
-  while (element.offsetParent && element !== stop) {
-    offset += element.offsetTop;
-    element = element.offsetParent as HTMLElement;
-  }
-  return offset;
-}
+export const formatDateGroupTitle = memoize(formatGroupTitle);
diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts
index 2f425b847d..3e2ed4e5c3 100644
--- a/web/src/lib/utils/tunables.ts
+++ b/web/src/lib/utils/tunables.ts
@@ -10,56 +10,17 @@ function getNumber(string: string | null, fallback: number) {
   }
   return Number.parseInt(string);
 }
-function getFloat(string: string | null, fallback: number) {
-  if (string === null) {
-    return fallback;
-  }
-  return Number.parseFloat(string);
-}
 export const TUNABLES = {
   LAYOUT: {
     WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
   },
-  SCROLL_TASK_QUEUE: {
-    TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25),
-    TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5),
-    TRICKLE_ACCELERATED_MIN_DELAY: getNumber(
-      localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'),
-      8,
-    ),
-    TRICKLE_ACCELERATED_MAX_DELAY: getNumber(
-      localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'),
-      2000,
-    ),
-    DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15),
-    DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16),
-    MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200),
-    CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16),
-  },
-  INTERSECTION_OBSERVER_QUEUE: {
-    DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15),
-    THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16),
-    THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true),
+  TIMELINE: {
+    INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
+    INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
   },
   ASSET_GRID: {
     NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
   },
-  BUCKET: {
-    PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2),
-    INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%',
-    INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%',
-  },
-  DATEGROUP: {
-    PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4),
-    INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false),
-    INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%',
-    INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%',
-  },
-  THUMBNAIL: {
-    PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8),
-    INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%',
-    INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%',
-  },
   IMAGE_THUMBNAIL: {
     THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
   },
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 763050df82..603250aecc 100644
--- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -100,7 +100,7 @@
   let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
 
   let backUrl: string = $state(AppRoute.ALBUMS);
-  let viewMode = $state(AlbumPageViewMode.VIEW);
+  let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
   let isCreatingSharedAlbum = $state(false);
   let isShowActivity = $state(false);
   let isLiked: ActivityResponseDto | null = $state(null);
@@ -203,7 +203,9 @@
 
   const handleStartSlideshow = async () => {
     const asset =
-      $slideshowNavigation === SlideshowNavigation.Shuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
+      $slideshowNavigation === SlideshowNavigation.Shuffle
+        ? await assetStore.getRandomAsset()
+        : assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
     if (asset) {
       setAsset(asset);
       $slideshowState = SlideshowState.PlaySlideshow;
@@ -211,6 +213,7 @@
   };
 
   const handleEscape = async () => {
+    assetStore.suspendTransitions = true;
     if (viewMode === AlbumPageViewMode.SELECT_USERS) {
       viewMode = AlbumPageViewMode.VIEW;
       return;
@@ -270,11 +273,8 @@
   };
 
   const setModeToView = async () => {
+    assetStore.suspendTransitions = true;
     viewMode = AlbumPageViewMode.VIEW;
-    assetStore.destroy();
-    assetStore = new AssetStore({ albumId, order: albumOrder });
-    timelineStore.destroy();
-    timelineStore = new AssetStore({ isArchived: false }, albumId);
     await navigate(
       { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
       { replaceState: true, forceNavigate: true },
@@ -394,14 +394,8 @@
     }
   });
 
-  onDestroy(() => {
-    assetStore.destroy();
-    timelineStore.destroy();
-  });
-
   let album = $state(data.album);
   let albumId = $derived(album.id);
-  let albumKey = $derived(`${albumId}_${albumOrder}`);
 
   $effect(() => {
     if (!album.isActivityEnabled && $numberOfComments === 0) {
@@ -409,8 +403,18 @@
     }
   });
 
-  let assetStore = $derived(new AssetStore({ albumId, order: albumOrder }));
-  let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId));
+  let assetStore = new AssetStore();
+  $effect(() => {
+    if (viewMode === AlbumPageViewMode.VIEW) {
+      void assetStore.updateOptions({ albumId, order: albumOrder });
+    } else if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
+      void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
+    }
+  });
+  onDestroy(() => assetStore.destroy());
+  // let timelineStore = new AssetStore();
+  // $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
+  // onDestroy(() => timelineStore.destroy());
 
   let isOwned = $derived($user.id == album.ownerId);
 
@@ -429,6 +433,22 @@
       handlePromiseError(getNumberOfComments());
     }
   });
+  const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
+  const isSelectionMode = $derived(
+    viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
+  );
+  const singleSelect = $derived(
+    viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
+  );
+  const showArchiveIcon = $derived(viewMode !== AlbumPageViewMode.SELECT_ASSETS);
+  const onSelect = ({ id }: { id: string }) => {
+    if (viewMode !== AlbumPageViewMode.SELECT_ASSETS) {
+      void handleUpdateThumbnail(id);
+    }
+  };
+  const currentAssetIntersection = $derived(
+    viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineInteraction : assetInteraction,
+  );
 </script>
 
 <div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
@@ -445,7 +465,14 @@
           <AddToAlbum shared />
         </ButtonContextMenu>
         {#if assetInteraction.isAllUserOwned}
-          <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
+          <FavoriteAction
+            removeFavorite={assetInteraction.isAllFavorite}
+            onFavorite={(ids, isFavorite) =>
+              assetStore.updateAssetOperation(ids, (asset) => {
+                asset.isFavorite = isFavorite;
+                return { remove: false };
+              })}
+          ></FavoriteAction>
         {/if}
         <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
           <DownloadAction menuItem filename="{album.albumName}.zip" />
@@ -482,6 +509,7 @@
               <CircleIconButton
                 title={$t('add_photos')}
                 onclick={async () => {
+                  assetStore.suspendTransitions = true;
                   viewMode = AlbumPageViewMode.SELECT_ASSETS;
                   oldAt = { at: $gridScrollTarget?.at };
                   await navigate(
@@ -576,127 +604,117 @@
     {/if}
 
     <main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
-      <!-- Use key because AssetGrid can't deal with changing stores -->
-      {#key albumKey}
-        {#if viewMode === AlbumPageViewMode.SELECT_ASSETS}
-          <AssetGrid
-            enableRouting={false}
-            assetStore={timelineStore}
-            assetInteraction={timelineInteraction}
-            isSelectionMode={true}
-          />
-        {:else}
-          <AssetGrid
-            enableRouting={true}
-            {album}
-            {assetStore}
-            {assetInteraction}
-            isShared={album.albumUsers.length > 0}
-            isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
-            singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
-            showArchiveIcon
-            onSelect={({ id }) => handleUpdateThumbnail(id)}
-            onEscape={handleEscape}
-          >
-            {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
-              <!-- ALBUM TITLE -->
-              <section class="pt-8 md:pt-24">
-                <AlbumTitle
-                  id={album.id}
-                  albumName={album.albumName}
-                  {isOwned}
-                  onUpdate={(albumName) => (album.albumName = albumName)}
-                />
+      <AssetGrid
+        enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
+        {album}
+        {assetStore}
+        assetInteraction={currentAssetIntersection}
+        {isShared}
+        {isSelectionMode}
+        {singleSelect}
+        {showArchiveIcon}
+        {onSelect}
+        onEscape={handleEscape}
+      >
+        {#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
+          {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
+            <!-- ALBUM TITLE -->
+            <section class="pt-8 md:pt-24">
+              <AlbumTitle
+                id={album.id}
+                albumName={album.albumName}
+                {isOwned}
+                onUpdate={(albumName) => (album.albumName = albumName)}
+              />
 
-                {#if album.assetCount > 0}
-                  <AlbumSummary {album} />
-                {/if}
+              {#if album.assetCount > 0}
+                <AlbumSummary {album} />
+              {/if}
 
-                <!-- ALBUM SHARING -->
-                {#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)}
-                  <div class="my-3 flex gap-x-1">
-                    <!-- link -->
-                    {#if album.hasSharedLink && isOwned}
-                      <CircleIconButton
-                        title={$t('create_link_to_share')}
-                        color="gray"
-                        size="20"
-                        icon={mdiLink}
-                        onclick={() => (viewMode = AlbumPageViewMode.LINK_SHARING)}
-                      />
-                    {/if}
+              <!-- ALBUM SHARING -->
+              {#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)}
+                <div class="my-3 flex gap-x-1">
+                  <!-- link -->
+                  {#if album.hasSharedLink && isOwned}
+                    <CircleIconButton
+                      title={$t('create_link_to_share')}
+                      color="gray"
+                      size="20"
+                      icon={mdiLink}
+                      onclick={() => (viewMode = AlbumPageViewMode.LINK_SHARING)}
+                    />
+                  {/if}
 
-                    <!-- owner -->
-                    <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
-                      <UserAvatar user={album.owner} size="md" />
-                    </button>
-
-                    <!-- users with write access (collaborators) -->
-                    {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
-                      <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
-                        <UserAvatar {user} size="md" />
-                      </button>
-                    {/each}
-
-                    <!-- display ellipsis if there are readonly users too -->
-                    {#if albumHasViewers}
-                      <CircleIconButton
-                        title={$t('view_all_users')}
-                        color="gray"
-                        size="20"
-                        icon={mdiDotsVertical}
-                        onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}
-                      />
-                    {/if}
-
-                    {#if isOwned}
-                      <CircleIconButton
-                        color="gray"
-                        size="20"
-                        icon={mdiPlus}
-                        onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
-                        title={$t('add_more_users')}
-                      />
-                    {/if}
-                  </div>
-                {/if}
-                <!-- ALBUM DESCRIPTION -->
-                <AlbumDescription id={album.id} bind:description={album.description} {isOwned} />
-              </section>
-            {/if}
-
-            {#if album.assetCount === 0}
-              <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
-                <div class="w-[300px]">
-                  <p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p>
-                  <button
-                    type="button"
-                    onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)}
-                    class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
-                  >
-                    <span class="text-text-immich-primary dark:text-immich-dark-primary"
-                      ><Icon path={mdiPlus} size="24" />
-                    </span>
-                    <span class="text-lg">{$t('select_photos')}</span>
+                  <!-- owner -->
+                  <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
+                    <UserAvatar user={album.owner} size="md" />
                   </button>
-                </div>
-              </section>
-            {/if}
-          </AssetGrid>
-        {/if}
 
-        {#if showActivityStatus}
-          <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
-            <ActivityStatus
-              disabled={!album.isActivityEnabled}
-              {isLiked}
-              numberOfComments={$numberOfComments}
-              onFavorite={handleFavorite}
-              onOpenActivityTab={handleOpenAndCloseActivityTab}
-            />
-          </div>
+                  <!-- users with write access (collaborators) -->
+                  {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
+                    <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
+                      <UserAvatar {user} size="md" />
+                    </button>
+                  {/each}
+
+                  <!-- display ellipsis if there are readonly users too -->
+                  {#if albumHasViewers}
+                    <CircleIconButton
+                      title={$t('view_all_users')}
+                      color="gray"
+                      size="20"
+                      icon={mdiDotsVertical}
+                      onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}
+                    />
+                  {/if}
+
+                  {#if isOwned}
+                    <CircleIconButton
+                      color="gray"
+                      size="20"
+                      icon={mdiPlus}
+                      onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
+                      title={$t('add_more_users')}
+                    />
+                  {/if}
+                </div>
+              {/if}
+              <!-- ALBUM DESCRIPTION -->
+              <AlbumDescription id={album.id} bind:description={album.description} {isOwned} />
+            </section>
+          {/if}
+
+          {#if album.assetCount === 0}
+            <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
+              <div class="w-[300px]">
+                <p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p>
+                <button
+                  type="button"
+                  onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)}
+                  class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
+                >
+                  <span class="text-text-immich-primary dark:text-immich-dark-primary"
+                    ><Icon path={mdiPlus} size="24" />
+                  </span>
+                  <span class="text-lg">{$t('select_photos')}</span>
+                </button>
+              </div>
+            </section>
+          {/if}
         {/if}
-      {/key}
+      </AssetGrid>
+
+      {#if showActivityStatus}
+        <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
+          <ActivityStatus
+            disabled={!album.isActivityEnabled}
+            {isLiked}
+            numberOfComments={$numberOfComments}
+            onFavorite={handleFavorite}
+            onOpenActivityTab={handleOpenAndCloseActivityTab}
+          />
+        </div>
+      {/if}
     </main>
   </div>
   {#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
index c8b239218a..86cfefff77 100644
--- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -12,20 +12,23 @@
   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
   import { AssetAction } from '$lib/constants';
-  import { AssetStore } from '$lib/stores/assets-store.svelte';
+
   import type { PageData } from './$types';
   import { mdiPlus, mdiDotsVertical } from '@mdi/js';
   import { t } from 'svelte-i18n';
   import { onDestroy } from 'svelte';
   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
+  import { AssetStore } from '$lib/stores/assets-store.svelte';
 
   interface Props {
     data: PageData;
   }
 
   let { data }: Props = $props();
+  const assetStore = new AssetStore();
+  void assetStore.updateOptions({ isArchived: true });
+  onDestroy(() => assetStore.destroy());
 
-  const assetStore = new AssetStore({ isArchived: true });
   const assetInteraction = new AssetInteraction();
 
   const handleEscape = () => {
@@ -34,10 +37,6 @@
       return;
     }
   };
-
-  onDestroy(() => {
-    assetStore.destroy();
-  });
 </script>
 
 {#if assetInteraction.selectionActive}
@@ -45,14 +44,28 @@
     assets={assetInteraction.selectedAssets}
     clearSelect={() => assetInteraction.clearMultiselect()}
   >
-    <ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
+    <ArchiveAction
+      unarchive
+      onArchive={(ids, isArchived) =>
+        assetStore.updateAssetOperation(ids, (asset) => {
+          asset.isArchived = isArchived;
+          return { remove: false };
+        })}
+    />
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteraction} />
     <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
       <AddToAlbum />
       <AddToAlbum shared />
     </ButtonContextMenu>
-    <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
+    <FavoriteAction
+      removeFavorite={assetInteraction.isAllFavorite}
+      onFavorite={(ids, isFavorite) =>
+        assetStore.updateAssetOperation(ids, (asset) => {
+          asset.isFavorite = isFavorite;
+          return { remove: false };
+        })}
+    />
     <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
       <DownloadAction menuItem />
       <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 02cac3644d..120281b07e 100644
--- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -29,7 +29,10 @@
 
   let { data }: Props = $props();
 
-  const assetStore = new AssetStore({ isFavorite: true });
+  const assetStore = new AssetStore();
+  void assetStore.updateOptions({ isFavorite: true });
+  onDestroy(() => assetStore.destroy());
+
   const assetInteraction = new AssetInteraction();
 
   const handleEscape = () => {
@@ -38,10 +41,6 @@
       return;
     }
   };
-
-  onDestroy(() => {
-    assetStore.destroy();
-  });
 </script>
 
 <!-- Multiselection mode app bar -->
diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
index c412aa8ea2..160c236049 100644
--- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -98,7 +98,19 @@
         <AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} />
         <AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared />
       </ButtonContextMenu>
-      <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} />
+      <FavoriteAction
+        removeFavorite={assetInteraction.isAllFavorite}
+        onFavorite={(ids, isFavorite) => {
+          if (data.pathAssets && data.pathAssets.length > 0) {
+            for (const id of ids) {
+              const asset = data.pathAssets.find((asset) => asset.id === id);
+              if (asset) {
+                asset.isFavorite = isFavorite;
+              }
+            }
+          }
+        }}
+      />
 
       <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
         <DownloadAction menuItem />
diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 2239a21cd5..0a71f35ff2 100644
--- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -123,7 +123,7 @@
 
   async function navigateRandom() {
     if (viewingAssets.length <= 0) {
-      return null;
+      return undefined;
     }
     const index = Math.floor(Math.random() * viewingAssets.length);
     const asset = await setAssetId(viewingAssets[index]);
diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 7885086b44..22a0d82cf4 100644
--- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -21,7 +21,9 @@
 
   let { data }: Props = $props();
 
-  const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true });
+  const assetStore = new AssetStore();
+  $effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true }));
+  onDestroy(() => assetStore.destroy());
   const assetInteraction = new AssetInteraction();
 
   const handleEscape = () => {
@@ -30,10 +32,6 @@
       return;
     }
   };
-
-  onDestroy(() => {
-    assetStore.destroy();
-  });
 </script>
 
 <main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg">
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte
index f5b7d13112..45e18fd398 100644
--- a/web/src/routes/(user)/people/+page.svelte
+++ b/web/src/routes/(user)/people/+page.svelte
@@ -456,10 +456,10 @@
 </UserPageLayout>
 
 {#if selectHidden}
-  <div
+  <dialog
+    open
     transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
     class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
-    role="dialog"
     aria-modal="true"
     aria-labelledby="manage-visibility-title"
     use:focusTrap
@@ -471,5 +471,5 @@
       onClose={() => (selectHidden = false)}
       {loadNextPage}
     />
-  </div>
+  </dialog>
 {/if}
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 6cad217377..c41ae7d85c 100644
--- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -74,14 +74,9 @@
   let numberOfAssets = $state(data.statistics.assets);
   let { isViewing: showAssetViewer } = assetViewingStore;
 
-  const assetStoreOptions = { isArchived: false, personId: data.person.id };
-  const assetStore = new AssetStore(assetStoreOptions);
-
-  $effect(() => {
-    // Check to trigger rebuild the timeline when navigating between people from the info panel
-    assetStoreOptions.personId = data.person.id;
-    handlePromiseError(assetStore.updateOptions(assetStoreOptions));
-  });
+  const assetStore = new AssetStore();
+  $effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id }));
+  onDestroy(() => assetStore.destroy());
 
   const assetInteraction = new AssetInteraction();
 
@@ -360,9 +355,6 @@
     await updateAssetCount();
   };
 
-  onDestroy(() => {
-    assetStore.destroy();
-  });
   let person = $derived(data.person);
 
   let thumbnailData = $derived(getPeopleThumbnailUrl(person));
@@ -418,7 +410,14 @@
         <AddToAlbum />
         <AddToAlbum shared />
       </ButtonContextMenu>
-      <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
+      <FavoriteAction
+        removeFavorite={assetInteraction.isAllFavorite}
+        onFavorite={(ids, isFavorite) =>
+          assetStore.updateAssetOperation(ids, (asset) => {
+            asset.isFavorite = isFavorite;
+            return { remove: false };
+          })}
+      />
       <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
         <DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
         <MenuOption
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 1b6ff3071a..131df3126d 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -33,7 +33,10 @@
   import { t } from 'svelte-i18n';
 
   let { isViewing: showAssetViewer } = assetViewingStore;
-  const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
+  const assetStore = new AssetStore();
+  void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true });
+  onDestroy(() => assetStore.destroy());
+
   const assetInteraction = new AssetInteraction();
 
   let selectedAssets = $derived(assetInteraction.selectedAssetsArray);
@@ -67,10 +70,6 @@
     assetStore.updateAssets([still]);
   };
 
-  onDestroy(() => {
-    assetStore.destroy();
-  });
-
   beforeNavigate(() => {
     isFaceEditMode.value = false;
   });
@@ -88,7 +87,14 @@
       <AddToAlbum />
       <AddToAlbum shared />
     </ButtonContextMenu>
-    <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
+    <FavoriteAction
+      removeFavorite={assetInteraction.isAllFavorite}
+      onFavorite={(ids, isFavorite) =>
+        assetStore.updateAssetOperation(ids, (asset) => {
+          asset.isFavorite = isFavorite;
+          return { remove: false };
+        })}
+    ></FavoriteAction>
     <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
       <DownloadAction menuItem />
       {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
index a35a30c1c4..c7f62cba0b 100644
--- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -136,13 +136,17 @@
     nextPage = 1;
     searchResultAssets = [];
     searchResultAlbums = [];
-    await loadNextPage();
+    await loadNextPage(true);
   }
 
-  const loadNextPage = async () => {
+  // eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
+  export const loadNextPage = async (force?: boolean) => {
     if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) {
       return;
     }
+    if (isLoading && !force) {
+      return;
+    }
     isLoading = true;
 
     const searchDto: SearchTerms = {
@@ -232,9 +236,6 @@
     return tagNames.join(', ');
   }
 
-  // eslint-disable-next-line no-self-assign
-  const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets);
-
   const onAddToAlbum = (assetIds: string[]) => {
     if (terms.isNotInAlbum.toString() == 'true') {
       const assetIdSet = new Set(assetIds);
@@ -262,13 +263,23 @@
           <AddToAlbum {onAddToAlbum} />
           <AddToAlbum shared {onAddToAlbum} />
         </ButtonContextMenu>
-        <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} />
+        <FavoriteAction
+          removeFavorite={assetInteraction.isAllFavorite}
+          onFavorite={(ids, isFavorite) => {
+            for (const id of ids) {
+              const asset = searchResultAssets.find((asset) => asset.id === id);
+              if (asset) {
+                asset.isFavorite = isFavorite;
+              }
+            }
+          }}
+        />
 
         <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
           <DownloadAction menuItem />
           <ChangeDate menuItem />
           <ChangeLocation menuItem />
-          <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} />
+          <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
           {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
             <TagAction menuItem />
           {/if}
@@ -281,6 +292,10 @@
   {:else}
     <div class="fixed z-[100] top-0 left-0 w-full">
       <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
+        <div
+          class="-z-[1] bg-immich-bg dark:bg-immich-dark-bg"
+          style="position:absolute;top:0;left:0;right:0;bottom:0;"
+        ></div>
         <div class="w-full flex-1 pl-4">
           <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
         </div>
@@ -329,45 +344,43 @@
 {/if}
 
 <section
-  class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
+  class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
   bind:clientHeight={viewport.height}
   bind:clientWidth={viewport.width}
 >
-  <section class="immich-scrollbar relative overflow-y-auto">
-    {#if searchResultAlbums.length > 0}
-      <section>
-        <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div>
-        <AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
+  {#if searchResultAlbums.length > 0}
+    <section>
+      <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div>
+      <AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
 
-        <div class="m-6 text-4xl font-medium text-black/70 dark:text-white/80">
-          {$t('photos_and_videos').toUpperCase()}
-        </div>
-      </section>
-    {/if}
-    <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
-      {#if searchResultAssets.length > 0}
-        <GalleryViewer
-          assets={searchResultAssets}
-          {assetInteraction}
-          onIntersected={loadNextPage}
-          showArchiveIcon={true}
-          {viewport}
-        />
-      {:else if !isLoading}
-        <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
-          <div class="flex flex-col content-center items-center text-center">
-            <Icon path={mdiImageOffOutline} size="3.5em" />
-            <p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
-            <p class="text-base font-normal">{$t('no_results_description')}</p>
-          </div>
-        </div>
-      {/if}
-
-      {#if isLoading}
-        <div class="flex justify-center py-16 items-center">
-          <LoadingSpinner size="48" />
-        </div>
-      {/if}
+      <div class="m-6 text-4xl font-medium text-black/70 dark:text-white/80">
+        {$t('photos_and_videos').toUpperCase()}
+      </div>
     </section>
+  {/if}
+  <section id="search-content">
+    {#if searchResultAssets.length > 0}
+      <GalleryViewer
+        assets={searchResultAssets}
+        {assetInteraction}
+        onIntersected={loadNextPage}
+        showArchiveIcon={true}
+        {viewport}
+      />
+    {:else if !isLoading}
+      <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
+        <div class="flex flex-col content-center items-center text-center">
+          <Icon path={mdiImageOffOutline} size="3.5em" />
+          <p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
+          <p class="text-base font-normal">{$t('no_results_description')}</p>
+        </div>
+      </div>
+    {/if}
+
+    {#if isLoading}
+      <div class="flex justify-center py-16 items-center">
+        <LoadingSpinner size="48" />
+      </div>
+    {/if}
   </section>
 </section>
diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 96bebe34c1..a89da7ad6b 100644
--- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -24,6 +24,7 @@
   import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
   import type { PageData } from './$types';
+  import { onDestroy } from 'svelte';
 
   interface Props {
     data: PageData;
@@ -39,8 +40,9 @@
   const buildMap = (tags: TagResponseDto[]) => {
     return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
   };
-
-  const assetStore = new AssetStore({});
+  const assetStore = new AssetStore();
+  $effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId }));
+  onDestroy(() => assetStore.destroy());
 
   let tags = $state<TagResponseDto[]>([]);
   $effect(() => {
@@ -52,10 +54,6 @@
   let tagId = $derived(tag?.id);
   let tree = $derived(buildTree(tags.map((tag) => tag.value)));
 
-  $effect.pre(() => {
-    void assetStore.updateOptions({ tagId });
-  });
-
   const handleNavigation = async (tag: string) => {
     await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
   };
diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
index e31929f2c5..209f75a302 100644
--- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -36,8 +36,10 @@
     handlePromiseError(goto(AppRoute.PHOTOS));
   }
 
-  const options = { isTrashed: true };
-  const assetStore = new AssetStore(options);
+  const assetStore = new AssetStore();
+  void assetStore.updateOptions({ isTrashed: true });
+  onDestroy(() => assetStore.destroy());
+
   const assetInteraction = new AssetInteraction();
 
   const handleEmptyTrash = async () => {
@@ -56,9 +58,6 @@
         message: $t('assets_permanently_deleted_count', { values: { count } }),
         type: NotificationType.Info,
       });
-
-      // reset asset grid (TODO fix in asset store that it should reset when it is empty)
-      await assetStore.updateOptions(options);
     } catch (error) {
       handleError(error, $t('errors.unable_to_empty_trash'));
     }
@@ -80,7 +79,10 @@
       });
 
       // reset asset grid (TODO fix in asset store that it should reset when it is empty)
-      await assetStore.updateOptions(options);
+      // note - this is still a problem, but updateOptions with the same value will not
+      // do anything, so need to flip it for it to reload/reinit
+      // await assetStore.updateOptions({ deferInit: true, isTrashed: true });
+      // await assetStore.updateOptions({ deferInit: false, isTrashed: true });
     } catch (error) {
       handleError(error, $t('errors.unable_to_restore_trash'));
     }
@@ -92,10 +94,6 @@
       return;
     }
   };
-
-  onDestroy(() => {
-    assetStore.destroy();
-  });
 </script>
 
 {#if assetInteraction.selectionActive}
diff --git a/web/tsconfig.json b/web/tsconfig.json
index 31aef23e31..c7bc16f52b 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -4,7 +4,7 @@
     "checkJs": true,
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
-    "module": "es2020",
+    "module": "es2022",
     "moduleResolution": "bundler",
     "resolveJsonModule": true,
     "skipLibCheck": true,