diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts
index e40c20388b..fe7da0b2c0 100644
--- a/e2e/src/web/specs/shared-link.e2e-spec.ts
+++ b/e2e/src/web/specs/shared-link.e2e-spec.ts
@@ -44,7 +44,7 @@ test.describe('Shared Links', () => {
   test('download from a shared link', async ({ page }) => {
     await page.goto(`/share/${sharedLink.key}`);
     await page.getByRole('heading', { name: 'Test Album' }).waitFor();
-    await page.locator('.group > div').first().hover();
+    await page.locator('.group').first().hover();
     await page.waitForSelector('#asset-group-by-date svg');
     await page.getByRole('checkbox').click();
     await page.getByRole('button', { name: 'Download' }).click();
diff --git a/web/src/lib/actions/autogrow.ts b/web/src/lib/actions/autogrow.ts
index b79671afc8..ff80454ef3 100644
--- a/web/src/lib/actions/autogrow.ts
+++ b/web/src/lib/actions/autogrow.ts
@@ -1,4 +1,7 @@
 export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
+  if (!textarea) {
+    return;
+  }
   textarea.style.height = height;
   textarea.style.height = `${textarea.scrollHeight}px`;
 };
diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts
new file mode 100644
index 0000000000..222f76be63
--- /dev/null
+++ b/web/src/lib/actions/intersection-observer.ts
@@ -0,0 +1,152 @@
+type Config = IntersectionObserverActionProperties & {
+  observer?: IntersectionObserver;
+};
+type TrackedProperties = {
+  root?: Element | Document | null;
+  threshold?: number | number[];
+  top?: string;
+  right?: string;
+  bottom?: string;
+  left?: string;
+};
+type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
+type OnSeperateCallback = (element: HTMLElement) => unknown;
+type IntersectionObserverActionProperties = {
+  key?: string;
+  onSeparate?: OnSeperateCallback;
+  onIntersect?: OnIntersectCallback;
+
+  root?: Element | Document | null;
+  threshold?: number | number[];
+  top?: string;
+  right?: string;
+  bottom?: string;
+  left?: string;
+
+  disabled?: boolean;
+};
+type TaskKey = HTMLElement | string;
+
+function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
+  return (
+    a?.bottom === b?.bottom &&
+    a?.top === b?.top &&
+    a?.left === b?.left &&
+    a?.right == b?.right &&
+    a?.threshold === b?.threshold &&
+    a?.root === b?.root
+  );
+}
+
+const elementToConfig = new Map<TaskKey, Config>();
+
+const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
+  if (!target.isConnected) {
+    elementToConfig.get(key)?.observer?.unobserve(target);
+    return;
+  }
+  const {
+    root,
+    threshold,
+    top = '0px',
+    right = '0px',
+    bottom = '0px',
+    left = '0px',
+    onSeparate,
+    onIntersect,
+  } = properties;
+  const rootMargin = `${top} ${right} ${bottom} ${left}`;
+  const observer = new IntersectionObserver(
+    (entries: IntersectionObserverEntry[]) => {
+      // This IntersectionObserver is limited to observing a single element, the one the
+      // action is attached to. If there are multiple entries, it means that this
+      // observer is being notified of multiple events that have occured quickly together,
+      // and the latest element is the one we are interested in.
+
+      entries.sort((a, b) => a.time - b.time);
+
+      const latestEntry = entries.pop();
+      if (latestEntry?.isIntersecting) {
+        onIntersect?.(latestEntry);
+      } else {
+        onSeparate?.(target);
+      }
+    },
+    {
+      rootMargin,
+      threshold,
+      root,
+    },
+  );
+  observer.observe(target);
+  elementToConfig.set(key, { ...properties, observer });
+};
+
+function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
+  elementToConfig.set(key, properties);
+  observe(key, element, properties);
+}
+
+function _intersectionObserver(
+  key: HTMLElement | string,
+  element: HTMLElement,
+  properties: IntersectionObserverActionProperties,
+) {
+  if (properties.disabled) {
+    properties.onIntersect?.(element);
+  } else {
+    configure(key, element, properties);
+  }
+  return {
+    update(properties: IntersectionObserverActionProperties) {
+      const config = elementToConfig.get(key);
+      if (!config) {
+        return;
+      }
+      if (isEquivalent(config, properties)) {
+        return;
+      }
+      configure(key, element, properties);
+    },
+    destroy: () => {
+      if (properties.disabled) {
+        properties.onSeparate?.(element);
+      } else {
+        const config = elementToConfig.get(key);
+        const { observer, onSeparate } = config || {};
+        observer?.unobserve(element);
+        elementToConfig.delete(key);
+        if (onSeparate) {
+          onSeparate?.(element);
+        }
+      }
+    },
+  };
+}
+
+export function intersectionObserver(
+  element: HTMLElement,
+  properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
+) {
+  // svelte doesn't allow multiple use:action directives of the same kind on the same element,
+  // so accept an array when multiple configurations are needed.
+  if (Array.isArray(properties)) {
+    if (!properties.every((p) => p.key)) {
+      throw new Error('Multiple configurations must specify key');
+    }
+    const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
+    return {
+      update: (properties: IntersectionObserverActionProperties[]) => {
+        for (const [i, props] of properties.entries()) {
+          observers[i].update(props);
+        }
+      },
+      destroy: () => {
+        for (const observer of observers) {
+          observer.destroy();
+        }
+      },
+    };
+  }
+  return _intersectionObserver(element, element, properties);
+}
diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts
new file mode 100644
index 0000000000..9f3adc44b0
--- /dev/null
+++ b/web/src/lib/actions/resize-observer.ts
@@ -0,0 +1,43 @@
+type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
+
+let observer: ResizeObserver;
+let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
+
+/**
+ * Installs a resizeObserver on the given element - when the element changes
+ * size, invokes a callback function with the width/height. Intended as a
+ * replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
+ * an iframe to measure the size of the element, which can be bad for
+ * performance and memory usage. In svelte5, they adapted bind:clientHeight and
+ * bind:clientWidth to use an internal resize observer.
+ *
+ * TODO: When svelte5 is ready, go back to bind:clientWidth and
+ * bind:clientHeight.
+ */
+export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
+  if (!observer) {
+    callbacks = new WeakMap();
+    observer = new ResizeObserver((entries) => {
+      for (const entry of entries) {
+        const onResize = callbacks.get(entry.target as HTMLElement);
+        if (onResize) {
+          onResize({
+            target: entry.target as HTMLElement,
+            width: entry.borderBoxSize[0].inlineSize,
+            height: entry.borderBoxSize[0].blockSize,
+          });
+        }
+      }
+    });
+  }
+
+  callbacks.set(element, onResize);
+  observer.observe(element);
+
+  return {
+    destroy: () => {
+      callbacks.delete(element);
+      observer.unobserve(element);
+    },
+  };
+}
diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts
new file mode 100644
index 0000000000..ab9d28ffc9
--- /dev/null
+++ b/web/src/lib/actions/thumbhash.ts
@@ -0,0 +1,14 @@
+import { decodeBase64 } from '$lib/utils';
+import { thumbHashToRGBA } from 'thumbhash';
+
+export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
+  const ctx = canvas.getContext('2d');
+  if (ctx) {
+    const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
+    const pixels = ctx.createImageData(w, h);
+    canvas.width = w;
+    canvas.height = h;
+    pixels.data.set(rgba);
+    ctx.putImageData(pixels, 0, 0);
+  }
+}
diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte
index 7a88aa740b..2256c79eb0 100644
--- a/web/src/lib/components/album-page/album-viewer.svelte
+++ b/web/src/lib/components/album-page/album-viewer.svelte
@@ -19,6 +19,7 @@
   import { handlePromiseError } from '$lib/utils';
   import AlbumSummary from './album-summary.svelte';
   import { t } from 'svelte-i18n';
+  import { onDestroy } from 'svelte';
 
   export let sharedLink: SharedLinkResponseDto;
   export let user: UserResponseDto | undefined = undefined;
@@ -38,6 +39,9 @@
       dragAndDropFilesStore.set({ isDragging: false, files: [] });
     }
   });
+  onDestroy(() => {
+    assetStore.destroy();
+  });
 </script>
 
 <svelte:window
@@ -94,7 +98,7 @@
 </header>
 
 <main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
-  <AssetGrid {album} {assetStore} {assetInteractionStore}>
+  <AssetGrid enableRouting={true} {album} {assetStore} {assetInteractionStore}>
     <section class="pt-8 md:pt-24">
       <!-- ALBUM TITLE -->
       <h1
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 7c541fbf7a..3ed955848b 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -15,7 +15,6 @@
   import { websocketEvents } from '$lib/stores/websocket';
   import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
   import { handleError } from '$lib/utils/handle-error';
-  import { navigate } from '$lib/utils/navigation';
   import { SlideshowHistory } from '$lib/utils/slideshow-history';
   import {
     AssetJobName,
@@ -70,7 +69,8 @@
   } = slideshowStore;
 
   const dispatch = createEventDispatcher<{
-    close: void;
+    action: { type: AssetAction; asset: AssetResponseDto };
+    close: { asset: AssetResponseDto };
     next: void;
     previous: void;
   }>();
@@ -201,7 +201,6 @@
       websocketEvents.on('on_asset_update', onAssetUpdate),
     );
 
-    await navigate({ targetRoute: 'current', assetId: asset.id });
     slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
       if (value === SlideshowState.PlaySlideshow) {
         slideshowHistory.reset();
@@ -268,9 +267,8 @@
     $isShowDetail = !$isShowDetail;
   };
 
-  const closeViewer = async () => {
-    dispatch('close');
-    await navigate({ targetRoute: 'current', assetId: null });
+  const closeViewer = () => {
+    dispatch('close', { asset });
   };
 
   const closeEditor = () => {
@@ -378,9 +376,7 @@
     }
   };
 
-  const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => {
-    const { isMouseOver } = e.detail;
-
+  const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
     previewStackedAsset = isMouseOver ? asset : undefined;
   };
 
@@ -392,8 +388,7 @@
       }
 
       case AssetAction.UNSTACK: {
-        await closeViewer();
-        break;
+        closeViewer();
       }
     }
 
@@ -585,12 +580,11 @@
                 ? 'bg-transparent border-2 border-white'
                 : 'bg-gray-700/40'} inline-block hover:bg-transparent"
               asset={stackedAsset}
-              onClick={(stackedAsset, event) => {
-                event.preventDefault();
+              onClick={(stackedAsset) => {
                 asset = stackedAsset;
                 preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
               }}
-              on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
+              onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
               readonly
               thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
               showStackedIcon={false}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 4ff2084b9a..88417f248f 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -212,7 +212,6 @@
                   title={person.name}
                   widthStyle="90px"
                   heightStyle="90px"
-                  thumbhash={null}
                   hidden={person.isHidden}
                 />
               </div>
diff --git a/web/src/lib/components/asset-viewer/intersection-observer.svelte b/web/src/lib/components/asset-viewer/intersection-observer.svelte
deleted file mode 100644
index df89a2ed7d..0000000000
--- a/web/src/lib/components/asset-viewer/intersection-observer.svelte
+++ /dev/null
@@ -1,82 +0,0 @@
-<script lang="ts">
-  import { BucketPosition } from '$lib/stores/assets.store';
-  import { createEventDispatcher, onMount } from 'svelte';
-
-  export let once = false;
-  export let top = 0;
-  export let bottom = 0;
-  export let left = 0;
-  export let right = 0;
-  export let root: HTMLElement | null = null;
-
-  export let intersecting = false;
-  let container: HTMLDivElement;
-  const dispatch = createEventDispatcher<{
-    hidden: HTMLDivElement;
-    intersected: {
-      container: HTMLDivElement;
-      position: BucketPosition;
-    };
-  }>();
-
-  onMount(() => {
-    if (typeof IntersectionObserver !== 'undefined') {
-      const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
-      const observer = new IntersectionObserver(
-        (entries) => {
-          intersecting = entries.some((entry) => entry.isIntersecting);
-          if (!intersecting) {
-            dispatch('hidden', container);
-          }
-
-          if (intersecting && once) {
-            observer.unobserve(container);
-          }
-
-          if (intersecting) {
-            let position: BucketPosition = BucketPosition.Visible;
-            if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
-              position = BucketPosition.Below;
-            } else if (entries[0].boundingClientRect.bottom < 0) {
-              position = BucketPosition.Above;
-            }
-
-            dispatch('intersected', {
-              container,
-              position,
-            });
-          }
-        },
-        {
-          rootMargin,
-          root,
-        },
-      );
-
-      observer.observe(container);
-      return () => observer.unobserve(container);
-    }
-
-    // The following is a fallback for older browsers
-    function handler() {
-      const bcr = container.getBoundingClientRect();
-
-      intersecting =
-        bcr.bottom + bottom > 0 &&
-        bcr.right + right > 0 &&
-        bcr.top - top < window.innerHeight &&
-        bcr.left - left < window.innerWidth;
-
-      if (intersecting && once) {
-        window.removeEventListener('scroll', handler);
-      }
-    }
-
-    window.addEventListener('scroll', handler);
-    return () => window.removeEventListener('scroll', handler);
-  });
-</script>
-
-<div bind:this={container}>
-  <slot {intersecting} />
-</div>
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte
index 966f382838..3919033e4a 100644
--- a/web/src/lib/components/asset-viewer/photo-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte
@@ -12,7 +12,7 @@
   import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
   import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
   import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
-  import { onDestroy } from 'svelte';
+  import { onDestroy, onMount } from 'svelte';
 
   import { fade } from 'svelte/transition';
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
@@ -33,6 +33,7 @@
   let imageLoaded: boolean = false;
   let imageError: boolean = false;
   let forceUseOriginal: boolean = false;
+  let loader: HTMLImageElement;
 
   $: isWebCompatible = isWebCompatibleImage(asset);
   $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
@@ -108,6 +109,25 @@
     event.preventDefault();
     handlePromiseError(copyImage());
   };
+
+  onMount(() => {
+    const onload = () => {
+      imageLoaded = true;
+      assetFileUrl = imageLoaderUrl;
+    };
+    const onerror = () => {
+      imageError = imageLoaded = true;
+    };
+    if (loader.complete) {
+      onload();
+    }
+    loader.addEventListener('load', onload);
+    loader.addEventListener('error', onerror);
+    return () => {
+      loader?.removeEventListener('load', onload);
+      loader?.removeEventListener('error', onerror);
+    };
+  });
 </script>
 
 <svelte:window
@@ -119,6 +139,8 @@
 {#if imageError}
   <div class="h-full flex items-center justify-center">{$t('error_loading_image')}</div>
 {/if}
+<!-- svelte-ignore a11y-missing-attribute -->
+<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
 <div bind:this={element} class="relative h-full select-none">
   <img
     style="display:none"
@@ -128,7 +150,7 @@
     on:error={() => (imageError = imageLoaded = true)}
   />
   {#if !imageLoaded}
-    <div class="flex h-full items-center justify-center">
+    <div id="spinner" class="flex h-full items-center justify-center">
       <LoadingSpinner />
     </div>
   {:else if !imageError}
@@ -159,3 +181,15 @@
     </div>
   {/if}
 </div>
+
+<style>
+  @keyframes delayedVisibility {
+    to {
+      visibility: visible;
+    }
+  }
+  #spinner {
+    visibility: hidden;
+    animation: 0s linear 0.4s forwards delayedVisibility;
+  }
+</style>
diff --git a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts
index 91ea7d3ab1..2525b86160 100644
--- a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts
+++ b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts
@@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte';
 
 describe('ImageThumbnail component', () => {
   beforeAll(() => {
-    Object.defineProperty(HTMLImageElement.prototype, 'decode', {
-      value: vi.fn(),
+    Object.defineProperty(HTMLImageElement.prototype, 'complete', {
+      value: true,
     });
   });
 
@@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => {
     const sut = render(ImageThumbnail, {
       url: 'http://localhost/img.png',
       altText: 'test',
-      thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
+      base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
       widthStyle: '250px',
     });
 
-    const [_, thumbhash] = sut.getAllByRole('img');
-    expect(thumbhash.getAttribute('src')).toContain(
-      'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated
-    );
+    const thumbhash = sut.getByTestId('thumbhash');
+    expect(thumbhash).not.toBeFalsy();
   });
 });
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
index 8e391ecb59..e03dd35653 100644
--- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
@@ -1,17 +1,19 @@
 <script lang="ts">
-  import { onMount, tick } from 'svelte';
-  import { decodeBase64 } from '$lib/utils';
+  import { onMount } from 'svelte';
+
   import { fade } from 'svelte/transition';
-  import { thumbHashToDataURL } from 'thumbhash';
-  import { mdiEyeOffOutline } from '@mdi/js';
+
+  import { thumbhash } from '$lib/actions/thumbhash';
   import Icon from '$lib/components/elements/icon.svelte';
+  import { TUNABLES } from '$lib/utils/tunables';
+  import { mdiEyeOffOutline, mdiImageBrokenVariant } from '@mdi/js';
 
   export let url: string;
   export let altText: string | undefined;
   export let title: string | null = null;
   export let heightStyle: string | undefined = undefined;
   export let widthStyle: string;
-  export let thumbhash: string | null = null;
+  export let base64ThumbHash: string | null = null;
   export let curve = false;
   export let shadow = false;
   export let circle = false;
@@ -19,37 +21,58 @@
   export let border = false;
   export let preload = true;
   export let hiddenIconClass = 'text-white';
+  export let onComplete: (() => void) | undefined = undefined;
+
+  let {
+    IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
+  } = TUNABLES;
+
+  let loaded = false;
+  let errored = false;
 
-  let complete = false;
   let img: HTMLImageElement;
 
-  onMount(async () => {
-    await img.decode();
-    await tick();
-    complete = true;
+  const setLoaded = () => {
+    loaded = true;
+    onComplete?.();
+  };
+  const setErrored = () => {
+    errored = true;
+    onComplete?.();
+  };
+  onMount(() => {
+    if (img.complete) {
+      setLoaded();
+    }
   });
 </script>
 
-<img
-  bind:this={img}
-  loading={preload ? 'eager' : 'lazy'}
-  style:width={widthStyle}
-  style:height={heightStyle}
-  style:filter={hidden ? 'grayscale(50%)' : 'none'}
-  style:opacity={hidden ? '0.5' : '1'}
-  src={url}
-  alt={altText}
-  {title}
-  class="object-cover transition duration-300 {border
-    ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
-    : ''}"
-  class:rounded-xl={curve}
-  class:shadow-lg={shadow}
-  class:rounded-full={circle}
-  class:aspect-square={circle || !heightStyle}
-  class:opacity-0={!thumbhash && !complete}
-  draggable="false"
-/>
+{#if errored}
+  <div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
+    <Icon path={mdiImageBrokenVariant} size="48" />
+  </div>
+{:else}
+  <img
+    bind:this={img}
+    on:load={setLoaded}
+    on:error={setErrored}
+    loading={preload ? 'eager' : 'lazy'}
+    style:width={widthStyle}
+    style:height={heightStyle}
+    style:filter={hidden ? 'grayscale(50%)' : 'none'}
+    style:opacity={hidden ? '0.5' : '1'}
+    src={url}
+    alt={loaded || errored ? altText : ''}
+    {title}
+    class="object-cover {border ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' : ''}"
+    class:rounded-xl={curve}
+    class:shadow-lg={shadow}
+    class:rounded-full={circle}
+    class:aspect-square={circle || !heightStyle}
+    class:opacity-0={!thumbhash && !loaded}
+    draggable="false"
+  />
+{/if}
 
 {#if hidden}
   <div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
@@ -57,18 +80,18 @@
   </div>
 {/if}
 
-{#if thumbhash && !complete}
-  <img
+{#if base64ThumbHash && (!loaded || errored)}
+  <canvas
+    use:thumbhash={{ base64ThumbHash }}
+    data-testid="thumbhash"
     style:width={widthStyle}
     style:height={heightStyle}
-    src={thumbHashToDataURL(decodeBase64(thumbhash))}
-    alt={altText}
     {title}
     class="absolute top-0 object-cover"
     class:rounded-xl={curve}
     class:shadow-lg={shadow}
     class:rounded-full={circle}
     draggable="false"
-    out:fade={{ duration: 300 }}
+    out:fade={{ duration: THUMBHASH_FADE_DURATION }}
   />
 {/if}
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index 6b0bd2ee75..c9fbf133c8 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-  import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
+  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';
@@ -18,18 +18,23 @@
     mdiMotionPlayOutline,
     mdiRotate360,
   } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
+
   import { fade } from 'svelte/transition';
   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';
 
-  const dispatch = createEventDispatcher<{
-    select: { asset: AssetResponseDto };
-    'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number };
-  }>();
+  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';
 
   export let asset: AssetResponseDto;
+  export let dateGroup: DateGroup | undefined = undefined;
+  export let assetStore: AssetStore | undefined = undefined;
   export let groupIndex = 0;
   export let thumbnailSize: number | undefined = undefined;
   export let thumbnailWidth: number | undefined = undefined;
@@ -40,72 +45,181 @@
   export let readonly = false;
   export let showArchiveIcon = false;
   export let showStackedIcon = true;
-  export let onClick: ((asset: AssetResponseDto, event: Event) => void) | undefined = undefined;
+  export let intersectionConfig: {
+    root?: HTMLElement;
+    bottom?: string;
+    top?: string;
+    left?: string;
+    priority?: number;
+    disabled?: boolean;
+  } = {};
+
+  export let retrieveElement: boolean = false;
+  export let onIntersected: (() => void) | undefined = undefined;
+  export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined;
+  export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined;
+  export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined;
+  export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined =
+    undefined;
 
   let className = '';
   export { className as class };
 
+  let {
+    IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
+  } = TUNABLES;
+
+  const componentId = generateId();
+  let element: HTMLElement | undefined;
   let mouseOver = false;
+  let intersecting = false;
+  let lastRetrievedElement: HTMLElement | undefined;
+  let loaded = false;
 
-  $: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
+  $: if (!retrieveElement) {
+    lastRetrievedElement = undefined;
+  }
+  $: if (retrieveElement && element && lastRetrievedElement !== element) {
+    lastRetrievedElement = element;
+    onRetrieveElement?.(element);
+  }
 
-  $: [width, height] = ((): [number, number] => {
-    if (thumbnailSize) {
-      return [thumbnailSize, thumbnailSize];
-    }
+  $: width = thumbnailSize || thumbnailWidth || 235;
+  $: height = thumbnailSize || thumbnailHeight || 235;
+  $: display = intersecting;
 
-    if (thumbnailWidth && thumbnailHeight) {
-      return [thumbnailWidth, thumbnailHeight];
-    }
-
-    return [235, 235];
-  })();
-
-  const onIconClickedHandler = (e: MouseEvent) => {
-    e.stopPropagation();
-    e.preventDefault();
+  const onIconClickedHandler = (e?: MouseEvent) => {
+    e?.stopPropagation();
+    e?.preventDefault();
     if (!disabled) {
-      dispatch('select', { asset });
+      onSelect?.(asset);
     }
   };
 
+  const callClickHandlers = () => {
+    if (selected) {
+      onIconClickedHandler();
+      return;
+    }
+    onClick?.(asset);
+  };
   const handleClick = (e: MouseEvent) => {
     if (e.ctrlKey || e.metaKey) {
       return;
     }
+    e.stopPropagation();
+    e.preventDefault();
+    callClickHandlers();
+  };
 
-    if (selected) {
-      onIconClickedHandler(e);
-      return;
-    }
-
-    onClick?.(asset, e);
+  const _onMouseEnter = () => {
+    mouseOver = true;
+    onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
   };
 
   const onMouseEnter = () => {
-    mouseOver = true;
+    if (dateGroup && assetStore) {
+      assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
+    } else {
+      _onMouseEnter();
+    }
   };
 
   const onMouseLeave = () => {
-    mouseOver = false;
+    if (dateGroup && assetStore) {
+      assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
+    } else {
+      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.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
+    } else {
+      intersecting = false;
+    }
+  };
+
+  onDestroy(() => {
+    assetStore?.taskManager.removeAllTasksForComponent(componentId);
+  });
 </script>
 
-<IntersectionObserver once={false} on:intersected let:intersecting>
-  <a
-    href={currentUrlReplaceAssetId(asset.id)}
-    style:width="{width}px"
-    style:height="{height}px"
-    class="group focus-visible:outline-none flex overflow-hidden {disabled
-      ? 'bg-gray-300'
-      : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
-    class:cursor-not-allowed={disabled}
-    on:mouseenter={onMouseEnter}
-    on:mouseleave={onMouseLeave}
-    tabindex={0}
-    on:click={handleClick}
-  >
-    {#if intersecting}
+<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="group focus-visible:outline-none flex overflow-hidden {disabled
+    ? 'bg-gray-300'
+    : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
+>
+  {#if !loaded && asset.thumbhash}
+    <canvas
+      use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
+      class="absolute object-cover z-10"
+      style:width="{width}px"
+      style:height="{height}px"
+      out:fade={{ duration: THUMBHASH_FADE_DURATION }}
+    ></canvas>
+  {/if}
+
+  {#if display}
+    <!-- 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:cursor-not-allowed={disabled}
+      class:cursor-pointer={!disabled}
+      on:mouseenter={onMouseEnter}
+      on:mouseleave={onMouseLeave}
+      on:keypress={(evt) => {
+        if (evt.key === 'Enter') {
+          callClickHandlers();
+        }
+      }}
+      tabindex={0}
+      on:click={handleClick}
+      role="link"
+    >
+      {#if mouseOver}
+        <!-- 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)}
+          on:click={(evt) => evt.preventDefault()}
+          tabindex={0}
+        >
+        </a>
+      {/if}
       <div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
         <!-- Select asset button  -->
         {#if !readonly && (mouseOver || selected || selectionCandidate)}
@@ -189,11 +303,11 @@
             altText={$getAltText(asset)}
             widthStyle="{width}px"
             heightStyle="{height}px"
-            thumbhash={asset.thumbhash}
             curve={selected}
+            onComplete={() => (loaded = true)}
           />
         {:else}
-          <div class="flex h-full w-full items-center justify-center p-4">
+          <div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
             <Icon path={mdiImageBrokenVariant} size="48" />
           </div>
         {/if}
@@ -201,6 +315,7 @@
         {#if asset.type === AssetTypeEnum.Video}
           <div class="absolute top-0 h-full w-full">
             <VideoThumbnail
+              {assetStore}
               url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
               enablePlayback={mouseOver && $playVideoThumbnailOnHover}
               curve={selected}
@@ -213,6 +328,7 @@
         {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
           <div class="absolute top-0 h-full w-full">
             <VideoThumbnail
+              {assetStore}
               url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
               pauseIcon={mdiMotionPauseOutline}
               playIcon={mdiMotionPlayOutline}
@@ -230,6 +346,6 @@
           out:fade={{ duration: 100 }}
         />
       {/if}
-    {/if}
-  </a>
-</IntersectionObserver>
+    </div>
+  {/if}
+</div>
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
index 5c4196e54b..5cac0b1945 100644
--- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
@@ -3,7 +3,11 @@
   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';
+  import { generateId } from '$lib/utils/generate-id';
+  import { onDestroy } from 'svelte';
 
+  export let assetStore: AssetStore | undefined = undefined;
   export let url: string;
   export let durationInSeconds = 0;
   export let enablePlayback = false;
@@ -13,6 +17,7 @@
   export let playIcon = mdiPlayCircleOutline;
   export let pauseIcon = mdiPauseCircleOutline;
 
+  const componentId = generateId();
   let remainingSeconds = durationInSeconds;
   let loading = true;
   let error = false;
@@ -27,6 +32,43 @@
       player.src = '';
     }
   }
+  const onMouseEnter = () => {
+    if (assetStore) {
+      assetStore.taskManager.queueScrollSensitiveTask({
+        componentId,
+        task: () => {
+          if (playbackOnIconHover) {
+            enablePlayback = true;
+          }
+        },
+      });
+    } else {
+      if (playbackOnIconHover) {
+        enablePlayback = true;
+      }
+    }
+  };
+
+  const onMouseLeave = () => {
+    if (assetStore) {
+      assetStore.taskManager.queueScrollSensitiveTask({
+        componentId,
+        task: () => {
+          if (playbackOnIconHover) {
+            enablePlayback = false;
+          }
+        },
+      });
+    } else {
+      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">
@@ -37,19 +79,7 @@
   {/if}
 
   <!-- svelte-ignore a11y-no-static-element-interactions -->
-  <span
-    class="pr-2 pt-2"
-    on:mouseenter={() => {
-      if (playbackOnIconHover) {
-        enablePlayback = true;
-      }
-    }}
-    on:mouseleave={() => {
-      if (playbackOnIconHover) {
-        enablePlayback = false;
-      }
-    }}
-  >
+  <span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
     {#if enablePlayback}
       {#if loading}
         <LoadingSpinner />
diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte
index 0dd4251dab..eba26e6e61 100644
--- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte
+++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte
@@ -113,7 +113,6 @@
                   title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
                   widthStyle="90px"
                   heightStyle="90px"
-                  thumbhash={null}
                   hidden={person.isHidden}
                 />
               </div>
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte
index 712100763c..fd4fbdf964 100644
--- a/web/src/lib/components/faces-page/person-side-panel.svelte
+++ b/web/src/lib/components/faces-page/person-side-panel.svelte
@@ -265,8 +265,6 @@
                       title={$t('face_unassigned')}
                       widthStyle="90px"
                       heightStyle="90px"
-                      thumbhash={null}
-                      hidden={false}
                     />
                   {:then data}
                     <ImageThumbnail
@@ -277,8 +275,6 @@
                       title={$t('face_unassigned')}
                       widthStyle="90px"
                       heightStyle="90px"
-                      thumbhash={null}
-                      hidden={false}
                     />
                   {/await}
                 {/if}
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte
index 2a6e121c3a..250cb379cc 100644
--- a/web/src/lib/components/memory-page/memory-viewer.svelte
+++ b/web/src/lib/components/memory-page/memory-viewer.svelte
@@ -2,7 +2,7 @@
   import { goto } from '$app/navigation';
   import { page } from '$app/stores';
   import { shortcuts } from '$lib/actions/shortcut';
-  import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
+
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
   import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
@@ -38,6 +38,8 @@
   import { tweened } from 'svelte/motion';
   import { fade } from 'svelte/transition';
   import { t } from 'svelte-i18n';
+  import { intersectionObserver } from '$lib/actions/intersection-observer';
+  import { resizeObserver } from '$lib/actions/resize-observer';
   import { locale } from '$lib/stores/preferences.store';
 
   const parseIndex = (s: string | null, max: number | null) =>
@@ -383,21 +385,18 @@
         />
       </div>
 
-      <IntersectionObserver
-        once={false}
-        on:intersected={() => (galleryInView = true)}
-        on:hidden={() => (galleryInView = false)}
-        bottom={-200}
+      <div
+        id="gallery-memory"
+        use:intersectionObserver={{
+          onIntersect: () => (galleryInView = true),
+          onSeparate: () => (galleryInView = false),
+          bottom: '-200px',
+        }}
+        use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}
+        bind:this={memoryGallery}
       >
-        <div
-          id="gallery-memory"
-          bind:this={memoryGallery}
-          bind:clientHeight={viewport.height}
-          bind:clientWidth={viewport.width}
-        >
-          <GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets />
-        </div>
-      </IntersectionObserver>
+        <GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets />
+      </div>
     </section>
   {/if}
 </section>
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 dd57160fb4..5ca29967fe 100644
--- a/web/src/lib/components/photos-page/asset-date-group.svelte
+++ b/web/src/lib/components/photos-page/asset-date-group.svelte
@@ -1,84 +1,69 @@
 <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 type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
-  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  import type { AssetStore, Viewport } from '$lib/stores/assets.store';
-  import { locale } from '$lib/stores/preferences.store';
-  import { getAssetRatio } from '$lib/utils/asset-utils';
-  import {
-    calculateWidth,
-    formatGroupTitle,
-    fromLocalDateTime,
-    splitBucketIntoDateGroups,
-  } from '$lib/utils/timeline-util';
+  import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store';
+  import { navigate } from '$lib/utils/navigation';
+  import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
   import type { AssetResponseDto } from '@immich/sdk';
   import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
-  import justifiedLayout from 'justified-layout';
-  import { createEventDispatcher } from 'svelte';
+  import { createEventDispatcher, 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';
 
-  export let assets: AssetResponseDto[];
-  export let bucketDate: string;
-  export let bucketHeight: number;
+  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 assetInteractionStore: AssetInteractionStore;
 
+  export let onScrollTarget: ScrollTargetListener | undefined = undefined;
+  export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
+
+  const componentId = generateId();
+  $: bucketDate = bucket.bucketDate;
+  $: dateGroups = bucket.dateGroups;
+
+  const {
+    DATEGROUP: { INTERSECTION_DISABLED, INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
+  } = TUNABLES;
+  /* TODO figure out a way to calculate this*/
+  const TITLE_HEIGHT = 51;
+
   const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
   const dispatch = createEventDispatcher<{
     select: { title: string; assets: AssetResponseDto[] };
     selectAssets: AssetResponseDto;
     selectAssetCandidates: AssetResponseDto | null;
-    shift: { heightDelta: number };
   }>();
 
   let isMouseOverGroup = false;
-  let actualBucketHeight: number;
   let hoveredDateGroup = '';
 
-  $: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
-
-  $: geometry = (() => {
-    const geometry = [];
-    for (let group of assetsGroupByDate) {
-      const justifiedLayoutResult = justifiedLayout(
-        group.map((assetGroup) => getAssetRatio(assetGroup)),
-        {
-          boxSpacing: 2,
-          containerWidth: Math.floor(viewport.width),
-          containerPadding: 0,
-          targetRowHeightTolerance: 0.15,
-          targetRowHeight: 235,
-        },
-      );
-      geometry.push({
-        ...justifiedLayoutResult,
-        containerWidth: calculateWidth(justifiedLayoutResult.boxes),
-      });
+  const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
+    if (isSelectionMode || $isMultiSelectState) {
+      assetSelectHandler(asset, assets, groupTitle);
+      return;
     }
-    return geometry;
-  })();
+    void navigate({ targetRoute: 'current', assetId: asset.id });
+  };
 
-  $: {
-    if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) {
-      const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
-      if (heightDelta !== 0) {
-        scrollTimeline(heightDelta);
-      }
+  const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
+    if (assetGridElement && onScrollTarget) {
+      const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
+      onScrollTarget({ bucket, dateGroup, asset, offset });
     }
-  }
-
-  function scrollTimeline(heightDelta: number) {
-    dispatch('shift', {
-      heightDelta,
-    });
-  }
+  };
 
   const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
 
@@ -104,93 +89,149 @@
       dispatch('selectAssetCandidates', asset);
     }
   };
+
+  onDestroy(() => {
+    $assetStore.taskManager.removeAllTasksForComponent(componentId);
+  });
 </script>
 
-<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
-  {#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
-    {@const asset = groupAssets[0]}
-    {@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))}
-    <!-- Asset Group By Date -->
+<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)}
 
-    <!-- svelte-ignore a11y-no-static-element-interactions -->
     <div
-      class="flex flex-col"
-      on:mouseenter={() => {
-        isMouseOverGroup = true;
-        assetMouseEventHandler(groupTitle, null);
-      }}
-      on:mouseleave={() => {
-        isMouseOverGroup = false;
-        assetMouseEventHandler(groupTitle, null);
+      id="date-group"
+      use:intersectionObserver={{
+        onIntersect: () => {
+          $assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
+            assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
+          );
+        },
+        onSeparate: () => {
+          $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
+            assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
+          );
+        },
+        top: INTERSECTION_ROOT_TOP,
+        bottom: INTERSECTION_ROOT_BOTTOM,
+        root: assetGridElement,
+        disabled: INTERSECTION_DISABLED,
       }}
+      data-display={display}
+      data-date-group={dateGroup.date}
+      style:height={dateGroup.height + 'px'}
+      style:width={dateGroup.geometry.containerWidth + 'px'}
+      style:overflow={'clip'}
     >
-      <!-- Date group 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"
-        style="width: {geometry[groupIndex].containerWidth}px"
-      >
-        {#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))}
+      {#if !display}
+        <Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
+      {/if}
+      {#if display}
+        <!-- Asset Group By Date -->
+        <!-- svelte-ignore a11y-no-static-element-interactions -->
+        <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);
+              },
+            });
+          }}
+        >
+          <!-- Date group title -->
           <div
-            transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
-            class="inline-block px-2 hover:cursor-pointer"
-            on:click={() => handleSelectGroup(groupTitle, groupAssets)}
-            on:keydown={() => handleSelectGroup(groupTitle, groupAssets)}
+            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={dateGroup.geometry.containerWidth + 'px'}
           >
-            {#if $selectedGroup.has(groupTitle)}
-              <Icon path={mdiCheckCircle} size="24" color="#4250af" />
-            {:else}
-              <Icon path={mdiCircleOutline} size="24" color="#757575" />
+            {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $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 $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={dateGroup.groupTitle}>
+              {dateGroup.groupTitle}
+            </span>
           </div>
-        {/if}
 
-        <span class="w-full truncate first-letter:capitalize" title={groupTitle}>
-          {groupTitle}
-        </span>
-      </div>
-
-      <!-- Image grid -->
-      <div
-        class="relative"
-        style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
-      >
-        {#each groupAssets as asset, index (asset.id)}
-          {@const box = geometry[groupIndex].boxes[index]}
+          <!-- Image grid -->
           <div
-            class="absolute"
-            style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
+            class="relative overflow-clip"
+            style:height={dateGroup.geometry.containerHeight + 'px'}
+            style:width={dateGroup.geometry.containerWidth + 'px'}
           >
-            <Thumbnail
-              showStackedIcon={withStacked}
-              {showArchiveIcon}
-              {asset}
-              {groupIndex}
-              onClick={(asset, event) => {
-                if (isSelectionMode || $isMultiSelectState) {
-                  event.preventDefault();
-                  assetSelectHandler(asset, groupAssets, groupTitle);
-                  return;
-                }
-
-                assetViewingStore.setAsset(asset);
-              }}
-              on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)}
-              on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)}
-              selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
-              selectionCandidate={$assetSelectionCandidates.has(asset)}
-              disabled={$assetStore.albumAssets.has(asset.id)}
-              thumbnailWidth={box.width}
-              thumbnailHeight={box.height}
-            />
+            {#each dateGroup.assets as asset, index (asset.id)}
+              {@const box = dateGroup.geometry.boxes[index]}
+              <!-- 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:width={box.width + 'px'}
+                style:height={box.height + 'px'}
+                style:top={box.top + 'px'}
+                style:left={box.left + '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={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
+                  selectionCandidate={$assetSelectionCandidates.has(asset)}
+                  disabled={$assetStore.albumAssets.has(asset.id)}
+                  thumbnailWidth={box.width}
+                  thumbnailHeight={box.height}
+                />
+              </div>
+            {/each}
           </div>
-        {/each}
-      </div>
+        </div>
+      {/if}
     </div>
   {/each}
 </section>
 
 <style>
   #asset-group-by-date {
-    contain: layout;
+    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 3e0935d938..db030ed14c 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -1,11 +1,17 @@
 <script lang="ts">
-  import { goto } from '$app/navigation';
+  import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
   import type { Action } from '$lib/components/asset-viewer/actions/action';
   import { AppRoute, AssetAction } from '$lib/constants';
   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  import { BucketPosition, isSelectingAllAssets, type AssetStore, type Viewport } from '$lib/stores/assets.store';
+  import {
+    AssetBucket,
+    AssetStore,
+    isSelectingAllAssets,
+    type BucketListener,
+    type ViewportXY,
+  } from '$lib/stores/assets.store';
   import { locale, showDeleteModal } from '$lib/stores/preferences.store';
   import { isSearchEnabled } from '$lib/stores/search.store';
   import { featureFlags } from '$lib/stores/server-config.store';
@@ -13,19 +19,38 @@
   import { deleteAssets } from '$lib/utils/actions';
   import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
   import { navigate } from '$lib/utils/navigation';
-  import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
+  import {
+    formatGroupTitle,
+    splitBucketIntoDateGroups,
+    type ScrubberListener,
+    type ScrollTargetListener,
+  } from '$lib/utils/timeline-util';
+  import { TUNABLES } from '$lib/utils/tunables';
   import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
-  import { DateTime } from 'luxon';
+  import { throttle } from 'lodash-es';
   import { createEventDispatcher, onDestroy, onMount } from 'svelte';
-  import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
   import Portal from '../shared-components/portal/portal.svelte';
-  import Scrollbar from '../shared-components/scrollbar/scrollbar.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 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';
+
   export let isSelectionMode = false;
   export let singleSelect = false;
+
+  /** `true` if this asset grid is responds to navigation events; if `true`, then look at the
+   `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
+   additionally, update the page location/url with the asset as the asset-grid is scrolled */
+  export let enableRouting: boolean;
+
   export let assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
   export let removeAction:
@@ -40,17 +65,32 @@
   export let album: AlbumResponseDto | null = null;
   export let isShowDeleteConfirmation = false;
 
-  $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
-
+  let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
   const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
     assetInteractionStore;
-  const viewport: Viewport = { width: 0, height: 0 };
-  let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets } = assetViewingStore;
+
+  const viewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
+  const safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
+
+  const componentId = generateId();
   let element: HTMLElement;
+  let timelineElement: HTMLElement;
   let showShortcuts = false;
   let showSkeleton = true;
+  let internalScroll = false;
+  let navigating = false;
+  let preMeasure: AssetBucket[] = [];
+  let lastIntersectedBucketDate: string | undefined;
+  let scrubBucketPercent = 0;
+  let scrubBucket: { bucketDate: string | undefined } | undefined;
+  let scrubOverallPercent: number = 0;
+  let topSectionHeight = 0;
+  let topSectionOffset = 0;
+  // 60 is the bottom spacer element at 60px
+  let bottomSectionHeight = 60;
+  let leadout = false;
 
-  $: timelineY = element?.scrollTop || 0;
+  $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
   $: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
   $: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
   $: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
@@ -59,30 +99,329 @@
       assetInteractionStore.clearMultiselect();
     }
   }
-
   $: {
-    void assetStore.updateViewport(viewport);
+    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();
+    }
   }
+  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 dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
 
-  onMount(async () => {
-    showSkeleton = false;
-    assetStore.connect();
-    await assetStore.init(viewport);
-  });
+  const isViewportOrigin = () => {
+    return viewport.height === 0 && viewport.width === 0;
+  };
 
-  onDestroy(() => {
-    if ($showAssetViewer) {
-      $showAssetViewer = false;
+  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;
     }
 
-    assetStore.disconnect();
+    if ($gridScrollTarget?.at) {
+      void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
+        element.scrollTo({ top: 0 });
+        showSkeleton = false;
+      });
+    } else {
+      element.scrollTo({ top: 0 });
+      showSkeleton = false;
+    }
+  };
+
+  afterNavigate((nav) => {
+    const { complete, type } = nav;
+    if (type === 'enter') {
+      return;
+    }
+    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
+    // that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
+    // this handler will run the navigation/scroll-to-asset handler when hmr is performed,
+    // preventing skeleton from showing after hmr
+    if (import.meta && import.meta.hot) {
+      const afterApdate = (payload: UpdatePayload) => {
+        const assetGridUpdate = payload.updates.some(
+          (update) => update.path.endsWith('asset-grid.svelte') || update.path.endsWith('assets-store.ts'),
+        );
+
+        if (assetGridUpdate) {
+          setTimeout(() => {
+            void $assetStore.updateViewport(safeViewport, true);
+            const asset = $page.url.searchParams.get('at');
+            if (asset) {
+              $gridScrollTarget = { at: asset };
+              void navigate(
+                { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
+                { replaceState: true, forceNavigate: true },
+              );
+            } else {
+              element.scrollTo({ top: 0 });
+              showSkeleton = false;
+            }
+          }, 500);
+        }
+      };
+      import.meta.hot?.on('vite:afterUpdate', afterApdate);
+      import.meta.hot?.on('vite:beforeUpdate', (payload) => {
+        const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
+        if (assetGridUpdate) {
+          assetStore.destroy();
+        }
+      });
+
+      return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
+    }
+    return () => void 0;
+  };
+
+  const _updateLastIntersectedBucketDate = () => {
+    let elem = document.elementFromPoint(safeViewport.x + 1, safeViewport.y + 1);
+
+    while (elem != null) {
+      if (elem.id === 'bucket') {
+        break;
+      }
+      elem = elem.parentElement;
+    }
+    if (elem) {
+      lastIntersectedBucketDate = (elem as HTMLElement).dataset.bucketDate;
+    }
+  };
+  const updateLastIntersectedBucketDate = throttle(_updateLastIntersectedBucketDate, 16, {
+    leading: false,
+    trailing: true,
+  });
+
+  const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
+    if (!lastIntersectedBucketDate) {
+      _updateLastIntersectedBucketDate();
+    }
+    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);
+    }
+  };
+
+  onMount(() => {
+    void $assetStore
+      .init({ bucketListener })
+      .then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport)));
+    if (!enableRouting) {
+      showSkeleton = false;
+    }
+    const dispose = hmrSupport();
+    return () => {
+      $assetStore.disconnect();
+      $assetStore.destroy();
+      dispose();
+    };
+  });
+
+  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 _updateViewport = () => void $assetStore.updateViewport(safeViewport);
+  const updateViewport = throttle(_updateViewport, 16);
+
+  const getMaxScrollPercent = () =>
+    ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
+    ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
+
+  const getMaxScroll = () =>
+    topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
+
+  const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
+    const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
+    const maxScrollPercent = getMaxScrollPercent();
+    const delta = bucket.bucketHeight * bucketScrollPercent;
+    const scrollTop = (topOffset + delta) * maxScrollPercent;
+    element.scrollTop = scrollTop;
+  };
+
+  const _onScrub: ScrubberListener = (
+    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
+
+      const maxScroll = getMaxScroll();
+      const offset = maxScroll * scrollPercent;
+      element.scrollTop = offset;
+    } else {
+      const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
+      if (!bucket) {
+        return;
+      }
+      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);
+      if (!bucket.loaded) {
+        await assetStore.loadBucket(bucket.bucketDate);
+      }
+      // Wait here, and collect the deltas that are above offset, which affect offset position
+      await bucket.measuredPromise;
+      scrollToBucketAndOffset(bucket, bucketScrollPercent);
+    }
+  };
+
+  const _handleTimelineScroll = () => {
+    leadout = false;
+    if ($assetStore.timelineHeight < safeViewport.height * 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);
+
+      scrubBucket = undefined;
+      scrubBucketPercent = 0;
+    } else {
+      let top = element?.scrollTop;
+      if (top < topSectionHeight) {
+        // in the lead-in area
+        scrubBucket = undefined;
+        scrubBucketPercent = 0;
+        const maxScroll = getMaxScroll();
+
+        scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
+        return;
+      }
+
+      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;
+        if (next < 0) {
+          scrubBucket = bucket;
+          scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
+          found = true;
+          break;
+        }
+        top = next;
+      }
+      if (!found) {
+        leadout = true;
+        scrubBucket = undefined;
+        scrubBucketPercent = 0;
+        scrubOverallPercent = 1;
+      }
+    }
+  };
+  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;
-    await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
+    await deleteAssets(
+      !(isTrashEnabled && !force),
+      (assetIds) => $assetStore.removeAssets(assetIds),
+      idsSelectedAssets,
+    );
     assetInteractionStore.clearMultiselect();
   };
 
@@ -107,7 +446,7 @@
   const onStackAssets = async () => {
     const ids = await stackAssets(Array.from($selectedAssets));
     if (ids) {
-      assetStore.removeAssets(ids);
+      $assetStore.removeAssets(ids);
       dispatch('escape');
     }
   };
@@ -115,7 +454,7 @@
   const toggleArchive = async () => {
     const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived);
     if (ids) {
-      assetStore.removeAssets(ids);
+      $assetStore.removeAssets(ids);
       deselectAllAssets();
     }
   };
@@ -135,7 +474,7 @@
       { shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
       { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
       { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
-      { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
+      { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) },
       { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
       { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
     ];
@@ -154,29 +493,33 @@
   })();
 
   const handleSelectAsset = (asset: AssetResponseDto) => {
-    if (!assetStore.albumAssets.has(asset.id)) {
+    if (!$assetStore.albumAssets.has(asset.id)) {
       assetInteractionStore.selectAsset(asset);
     }
   };
 
-  async function intersectedHandler(event: CustomEvent) {
-    const element_ = event.detail.container as HTMLElement;
-    const target = element_.firstChild as HTMLElement;
-    if (target) {
-      const bucketDate = target.id.split('_')[1];
-      await assetStore.loadBucket(bucketDate, event.detail.position);
-    }
+  function intersectedHandler(bucket: AssetBucket) {
+    updateLastIntersectedBucketDate();
+    const intersectedTask = () => {
+      $assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
+      void $assetStore.loadBucket(bucket.bucketDate);
+    };
+    $assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
   }
 
-  function handleScrollTimeline(event: CustomEvent) {
-    element.scrollBy(0, event.detail.heightDelta);
+  function seperatedHandler(bucket: AssetBucket) {
+    const seperatedTask = () => {
+      $assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
+      bucket.cancel();
+    };
+    $assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
   }
 
   const handlePrevious = async () => {
-    const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
+    const previousAsset = await $assetStore.getPreviousAsset($viewingAsset);
 
     if (previousAsset) {
-      const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
+      const preloadAsset = await $assetStore.getPreviousAsset(previousAsset);
       assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
       await navigate({ targetRoute: 'current', assetId: previousAsset.id });
     }
@@ -185,10 +528,10 @@
   };
 
   const handleNext = async () => {
-    const nextAsset = await assetStore.getNextAsset($viewingAsset);
+    const nextAsset = await $assetStore.getNextAsset($viewingAsset);
 
     if (nextAsset) {
-      const preloadAsset = await assetStore.getNextAsset(nextAsset);
+      const preloadAsset = await $assetStore.getNextAsset(nextAsset);
       assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
       await navigate({ targetRoute: 'current', assetId: nextAsset.id });
     }
@@ -196,7 +539,12 @@
     return !!nextAsset;
   };
 
-  const handleClose = () => assetViewingStore.showAssetViewer(false);
+  const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => {
+    assetViewingStore.showAssetViewer(false);
+    showSkeleton = true;
+    $gridScrollTarget = { at: asset.id };
+    await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
+  };
 
   const handleAction = async (action: Action) => {
     switch (action.type) {
@@ -206,7 +554,7 @@
       case AssetAction.DELETE: {
         // find the next asset to show or close the viewer
         // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-        (await handleNext()) || (await handlePrevious()) || handleClose();
+        (await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } }));
 
         // delete after find the next one
         assetStore.removeAssets([action.asset.id]);
@@ -232,20 +580,6 @@
     }
   };
 
-  let animationTick = false;
-
-  const handleTimelineScroll = () => {
-    if (animationTick) {
-      return;
-    }
-
-    animationTick = true;
-    window.requestAnimationFrame(() => {
-      timelineY = element?.scrollTop || 0;
-      animationTick = false;
-    });
-  };
-
   let lastAssetMouseEvent: AssetResponseDto | null = null;
 
   $: if (!lastAssetMouseEvent) {
@@ -355,7 +689,7 @@
       // 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, BucketPosition.Unknown);
+        await $assetStore.loadBucket(bucket.bucketDate);
         for (const asset of bucket.assets) {
           if (deselect) {
             assetInteractionStore.removeAssetFromMultiselectGroup(asset);
@@ -370,11 +704,10 @@
         const bucket = $assetStore.buckets[bucketIndex];
 
         // Split bucket into date groups and check each group
-        const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
-
+        const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
         for (const dateGroup of assetsGroupByDate) {
-          const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day'));
-          if (dateGroup.every((a) => $selectedAssets.has(a))) {
+          const dateGroupTitle = formatGroupTitle(dateGroup.date);
+          if (dateGroup.assets.every((a) => $selectedAssets.has(a))) {
             assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
           } else {
             assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
@@ -411,6 +744,9 @@
       e.preventDefault();
     }
   };
+  onDestroy(() => {
+    assetStore.taskManager.removeAllTasksForComponent(componentId);
+  });
 </script>
 
 <svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
@@ -427,78 +763,97 @@
   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
 {/if}
 
-<Scrollbar
+<Scrubber
+  invisible={showSkeleton}
   {assetStore}
-  height={viewport.height}
-  {timelineY}
-  on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
+  height={safeViewport.height}
+  timelineTopOffset={topSectionHeight}
+  timelineBottomOffset={bottomSectionHeight}
+  {leadout}
+  {scrubOverallPercent}
+  {scrubBucketPercent}
+  {scrubBucket}
+  {onScrub}
+  {stopScrub}
 />
 
 <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
 <section
   id="asset-grid"
-  class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty
-    ? 'm-0'
-    : 'ml-4 tall:ml-0 md:mr-[60px]'}"
+  class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
   tabindex="-1"
-  bind:clientHeight={viewport.height}
-  bind:clientWidth={viewport.width}
+  use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
   bind:this={element}
-  on:scroll={handleTimelineScroll}
+  on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
 >
-  <!-- skeleton -->
-  {#if showSkeleton}
-    <div class="mt-8 animate-pulse">
-      <div class="mb-2 h-4 w-24 rounded-full bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
-      <div class="flex w-[120%] flex-wrap">
-        {#each Array.from({ length: 100 }) as _}
-          <div class="m-[1px] h-[10em] w-[16em] bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
-        {/each}
-      </div>
-    </div>
-  {/if}
-
-  {#if element}
+  <section
+    use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
+    class:invisible={showSkeleton}
+  >
     <slot />
-
-    <!-- (optional) empty placeholder -->
     {#if isEmpty}
+      <!-- (optional) empty placeholder -->
       <slot name="empty" />
     {/if}
-    <section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
-      {#each $assetStore.buckets as bucket (bucket.bucketDate)}
-        <IntersectionObserver
-          on:intersected={intersectedHandler}
-          on:hidden={() => assetStore.cancelBucket(bucket)}
-          let:intersecting
-          top={750}
-          bottom={750}
-          root={element}
-        >
-          <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
-            {#if intersecting}
-              <AssetDateGroup
-                {withStacked}
-                {showArchiveIcon}
-                {assetStore}
-                {assetInteractionStore}
-                {isSelectionMode}
-                {singleSelect}
-                on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
-                on:shift={handleScrollTimeline}
-                on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
-                on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
-                assets={bucket.assets}
-                bucketDate={bucket.bucketDate}
-                bucketHeight={bucket.bucketHeight}
-                {viewport}
-              />
-            {/if}
-          </div>
-        </IntersectionObserver>
-      {/each}
-    </section>
-  {/if}
+  </section>
+
+  <section
+    bind:this={timelineElement}
+    id="virtual-timeline"
+    class:invisible={showSkeleton}
+    style:height={$assetStore.timelineHeight + 'px'}
+  >
+    {#each $assetStore.buckets as bucket (bucket.bucketDate)}
+      {@const isPremeasure = preMeasure.includes(bucket)}
+      {@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure}
+      <div
+        id="bucket"
+        use:intersectionObserver={{
+          onIntersect: () => intersectedHandler(bucket),
+          onSeparate: () => seperatedHandler(bucket),
+          top: BUCKET_INTERSECTION_ROOT_TOP,
+          bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
+          root: element,
+        }}
+        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}
+          <AssetDateGroup
+            assetGridElement={element}
+            renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
+            renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
+            {withStacked}
+            {showArchiveIcon}
+            {assetStore}
+            {assetInteractionStore}
+            {isSelectionMode}
+            {singleSelect}
+            {onScrollTarget}
+            {onAssetInGrid}
+            {bucket}
+            viewport={safeViewport}
+            on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
+            on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
+            on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
+          />
+        {/if}
+      </div>
+    {/each}
+    <div class="h-[60px]"></div>
+  </section>
 </section>
 
 <Portal target="body">
@@ -522,7 +877,7 @@
 
 <style>
   #asset-grid {
-    contain: layout;
+    contain: strict;
     scrollbar-width: none;
   }
 </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
new file mode 100644
index 0000000000..98e423ae94
--- /dev/null
+++ b/web/src/lib/components/photos-page/measure-date-group.svelte
@@ -0,0 +1,89 @@
+<script lang="ts" context="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';
+
+  export let assetStore: AssetStore;
+  export let bucket: AssetBucket;
+  export let onMeasured: () => void;
+
+  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: 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}
+    <div id="date-group" data-date-group={dateGroup.date}>
+      <div
+        use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: 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/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte
index 43c2958944..5bc55796ae 100644
--- a/web/src/lib/components/photos-page/memory-lane.svelte
+++ b/web/src/lib/components/photos-page/memory-lane.svelte
@@ -1,4 +1,5 @@
 <script lang="ts">
+  import { resizeObserver } from '$lib/actions/resize-observer';
   import Icon from '$lib/components/elements/icon.svelte';
   import { AppRoute, QueryParameter } from '$lib/constants';
   import { memoryStore } from '$lib/stores/memory.store';
@@ -38,7 +39,7 @@
     id="memory-lane"
     bind:this={memoryLaneElement}
     class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all"
-    bind:offsetWidth
+    use:resizeObserver={({ width }) => (offsetWidth = width)}
     on:scroll={onScroll}
   >
     {#if canScrollLeft || canScrollRight}
@@ -67,7 +68,7 @@
         {/if}
       </div>
     {/if}
-    <div class="inline-block" bind:offsetWidth={innerWidth}>
+    <div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
       {#each $memoryStore as memory, index (memory.yearsAgo)}
         {#if memory.assets.length > 0}
           <a
diff --git a/web/src/lib/components/photos-page/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte
new file mode 100644
index 0000000000..07836eb4db
--- /dev/null
+++ b/web/src/lib/components/photos-page/skeleton.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+  export let title: string | null = null;
+  export let height: string | null = null;
+</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>
+
+<style>
+  #skeleton {
+    background-image: url('/light_skeleton.png');
+    background-repeat: repeat;
+    background-size: 235px, 235px;
+  }
+  :global(.dark) #skeleton {
+    background-image: url('/dark_skeleton.png');
+  }
+  @keyframes delayedVisibility {
+    to {
+      visibility: visible;
+    }
+  }
+  #skeleton {
+    visibility: hidden;
+    animation: 0s linear 0.1s forwards delayedVisibility;
+  }
+</style>
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 f977d91a99..c7b49f6012 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
@@ -4,25 +4,25 @@
   import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
   import { AppRoute, AssetAction } from '$lib/constants';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  import type { BucketPosition, Viewport } from '$lib/stores/assets.store';
+  import type { Viewport } from '$lib/stores/assets.store';
   import { getAssetRatio } from '$lib/utils/asset-utils';
   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 { createEventDispatcher, onDestroy } from 'svelte';
+  import { onDestroy } from 'svelte';
   import { t } from 'svelte-i18n';
   import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
   import Portal from '../portal/portal.svelte';
-
-  const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
+  import { handlePromiseError } from '$lib/utils';
 
   export let assets: AssetResponseDto[];
   export let selectedAssets: Set<AssetResponseDto> = new Set();
   export let disableAssetSelect = false;
   export let showArchiveIcon = false;
   export let viewport: Viewport;
+  export let onIntersected: (() => void) | undefined = undefined;
   export let showAssetName = false;
 
   let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
@@ -127,18 +127,15 @@
         <Thumbnail
           {asset}
           readonly={disableAssetSelect}
-          onClick={async (asset, e) => {
-            e.preventDefault();
-
+          onClick={(asset) => {
             if (isMultiSelectionMode) {
               selectAssetHandler(asset);
               return;
             }
-            await viewAssetHandler(asset);
+            void viewAssetHandler(asset);
           }}
-          on:select={(e) => selectAssetHandler(e.detail.asset)}
-          on:intersected={(event) =>
-            i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
+          onSelect={(asset) => selectAssetHandler(asset)}
+          onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
           selected={selectedAssets.has(asset)}
           {showArchiveIcon}
           thumbnailWidth={geometry.boxes[i].width}
@@ -159,6 +156,15 @@
 <!-- Overlay Asset Viewer -->
 {#if $isViewerOpen}
   <Portal target="body">
-    <AssetViewer asset={$viewingAsset} onAction={handleAction} on:previous={handlePrevious} on:next={handleNext} />
+    <AssetViewer
+      asset={$viewingAsset}
+      onAction={handleAction}
+      on:previous={handlePrevious}
+      on:next={handleNext}
+      on:close={() => {
+        assetViewingStore.showAssetViewer(false);
+        handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
+      }}
+    />
   </Portal>
 {/if}
diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
deleted file mode 100644
index 9282c760c2..0000000000
--- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
+++ /dev/null
@@ -1,183 +0,0 @@
-<script lang="ts">
-  import type { AssetStore, AssetBucket } from '$lib/stores/assets.store';
-  import type { DateTime } from 'luxon';
-  import { fromLocalDateTime } from '$lib/utils/timeline-util';
-  import { createEventDispatcher } from 'svelte';
-  import { clamp } from 'lodash-es';
-  import { locale } from '$lib/stores/preferences.store';
-
-  export let timelineY = 0;
-  export let height = 0;
-  export let assetStore: AssetStore;
-
-  let isHover = false;
-  let isDragging = false;
-  let isAnimating = false;
-  let hoverLabel = '';
-  let hoverY = 0;
-  let clientY = 0;
-  let windowHeight = 0;
-  let scrollBar: HTMLElement | undefined;
-
-  const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height;
-  const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height);
-
-  const HOVER_DATE_HEIGHT = 30;
-  const MIN_YEAR_LABEL_DISTANCE = 16;
-
-  $: {
-    hoverY = clamp(height - windowHeight + clientY, 0, height);
-    if (scrollBar) {
-      const rect = scrollBar.getBoundingClientRect();
-      const x = rect.left + rect.width / 2;
-      const y = rect.top + Math.min(hoverY, height - 1);
-      updateLabel(x, y);
-    }
-  }
-
-  $: scrollY = toScrollY(timelineY);
-
-  class Segment {
-    public count = 0;
-    public height = 0;
-    public timeGroup = '';
-    public date!: DateTime;
-    public hasLabel = false;
-  }
-
-  const calculateSegments = (buckets: AssetBucket[]) => {
-    let height = 0;
-    let previous: Segment;
-    return buckets.map((bucket) => {
-      const segment = new Segment();
-      segment.count = bucket.assets.length;
-      segment.height = toScrollY(bucket.bucketHeight);
-      segment.timeGroup = bucket.bucketDate;
-      segment.date = fromLocalDateTime(segment.timeGroup);
-
-      if (previous?.date.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
-        previous.hasLabel = true;
-        height = 0;
-      }
-
-      height += segment.height;
-      previous = segment;
-      return segment;
-    });
-  };
-
-  $: segments = calculateSegments($assetStore.buckets);
-
-  const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
-  const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
-
-  const updateLabel = (cursorX: number, cursorY: number) => {
-    const segment = document.elementsFromPoint(cursorX, cursorY).find(({ id }) => id === 'time-segment');
-    if (!segment) {
-      return;
-    }
-    const attr = (segment as HTMLElement).dataset.date;
-    if (!attr) {
-      return;
-    }
-    hoverLabel = new Date(attr).toLocaleString($locale, {
-      month: 'short',
-      year: 'numeric',
-      timeZone: 'UTC',
-    });
-  };
-
-  const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
-    const wasDragging = isDragging;
-
-    isDragging = event.isDragging ?? isDragging;
-    clientY = event.clientY;
-
-    if (wasDragging === false && isDragging) {
-      scrollTimeline();
-    }
-
-    if (!isDragging || isAnimating) {
-      return;
-    }
-
-    isAnimating = true;
-
-    window.requestAnimationFrame(() => {
-      scrollTimeline();
-      isAnimating = false;
-    });
-  };
-</script>
-
-<svelte:window
-  bind:innerHeight={windowHeight}
-  on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
-  on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
-  on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
-/>
-
-<!-- svelte-ignore a11y-no-static-element-interactions -->
-
-{#if $assetStore.timelineHeight > height}
-  <div
-    id="immich-scrubbable-scrollbar"
-    class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
-    style:width={isDragging ? '100vw' : '60px'}
-    style:height={height + 'px'}
-    style:background-color={isDragging ? 'transparent' : 'transparent'}
-    draggable="false"
-    bind:this={scrollBar}
-    on:mouseenter={() => (isHover = true)}
-    on:mouseleave={() => (isHover = false)}
-  >
-    {#if isHover || isDragging}
-      <div
-        id="time-label"
-        class="pointer-events-none absolute right-0 z-[100] min-w-24 w-fit whitespace-nowrap 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"
-        style:top="{clamp(hoverY - HOVER_DATE_HEIGHT, 0, height - HOVER_DATE_HEIGHT - 2)}px"
-      >
-        {hoverLabel}
-      </div>
-    {/if}
-
-    <!-- Scroll Position Indicator Line -->
-    {#if !isDragging}
-      <div
-        class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
-        style:top="{scrollY}px"
-      />
-    {/if}
-    <!-- Time Segment -->
-    {#each segments as segment}
-      <div
-        id="time-segment"
-        class="relative"
-        data-date={segment.date}
-        style:height={segment.height + 'px'}
-        aria-label={segment.timeGroup + ' ' + segment.count}
-      >
-        {#if segment.hasLabel}
-          <div
-            aria-label={segment.timeGroup + ' ' + segment.count}
-            class="absolute right-0 bottom-0 z-10 pr-5 text-[12px] dark:text-immich-dark-fg font-immich-mono"
-          >
-            {segment.date.year}
-          </div>
-        {:else if segment.height > 5}
-          <div
-            aria-label={segment.timeGroup + ' ' + segment.count}
-            class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
-          />
-        {/if}
-      </div>
-    {/each}
-  </div>
-{/if}
-
-<style>
-  #immich-scrubbable-scrollbar,
-  #time-segment {
-    contain: layout;
-  }
-</style>
diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
new file mode 100644
index 0000000000..e2cc638650
--- /dev/null
+++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
@@ -0,0 +1,281 @@
+<script lang="ts">
+  import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets.store';
+  import type { DateTime } from 'luxon';
+  import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
+  import { clamp } from 'lodash-es';
+  import { onMount } from 'svelte';
+
+  export let timelineTopOffset = 0;
+  export let timelineBottomOffset = 0;
+  export let height = 0;
+  export let assetStore: AssetStore;
+  export let invisible = false;
+  export let scrubOverallPercent: number = 0;
+  export let scrubBucketPercent: number = 0;
+  export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined;
+  export let leadout: boolean = false;
+  export let onScrub: ScrubberListener | undefined = undefined;
+  export let startScrub: ScrubberListener | undefined = undefined;
+  export let stopScrub: ScrubberListener | undefined = undefined;
+
+  let isHover = false;
+  let isDragging = false;
+  let hoverLabel: string | undefined;
+  let bucketDate: string | undefined;
+  let hoverY = 0;
+  let clientY = 0;
+  let windowHeight = 0;
+  let scrollBar: HTMLElement | undefined;
+  let segments: Segment[] = [];
+
+  const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
+  const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
+
+  const HOVER_DATE_HEIGHT = 31.75;
+  const MIN_YEAR_LABEL_DISTANCE = 16;
+  const MIN_DOT_DISTANCE = 8;
+
+  const toScrollFromBucketPercentage = (
+    scrubBucket: { bucketDate: string | undefined } | undefined,
+    scrubBucketPercent: number,
+    scrubOverallPercent: number,
+  ) => {
+    if (scrubBucket) {
+      let offset = relativeTopOffset;
+      let match = false;
+      for (const segment of segments) {
+        if (segment.bucketDate === scrubBucket.bucketDate) {
+          offset += scrubBucketPercent * segment.height;
+          match = true;
+          break;
+        }
+        offset += segment.height;
+      }
+      if (!match) {
+        offset += scrubBucketPercent * relativeBottomOffset;
+      }
+      // 2px is the height of the indicator
+      return offset - 2;
+    } else if (leadout) {
+      let offset = relativeTopOffset;
+      for (const segment of segments) {
+        offset += segment.height;
+      }
+      offset += scrubOverallPercent * relativeBottomOffset;
+      return offset - 2;
+    } else {
+      // 2px is the height of the indicator
+      return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
+    }
+  };
+  $: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
+  $: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset;
+  $: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight);
+  $: relativeBottomOffset = 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;
+    dateFormatted: string;
+    bucketDate: string;
+    date: DateTime;
+    hasLabel: boolean;
+    hasDot: boolean;
+  };
+
+  const calculateSegments = (buckets: AssetBucket[]) => {
+    let height = 0;
+    let dotHeight = 0;
+
+    let segments: Segment[] = [];
+    let previousLabeledSegment: Segment | undefined;
+
+    for (const [i, bucket] of buckets.entries()) {
+      const scrollBarPercentage =
+        bucket.bucketHeight / ($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
+
+      const segment = {
+        count: bucket.assets.length,
+        height: toScrollY(scrollBarPercentage),
+        bucketDate: bucket.bucketDate,
+        date: fromLocalDateTime(bucket.bucketDate),
+        dateFormatted: bucket.bucketDateFormattted,
+        hasLabel: false,
+        hasDot: false,
+      };
+
+      if (i === 0) {
+        segment.hasDot = true;
+        segment.hasLabel = true;
+        previousLabeledSegment = segment;
+      } else {
+        if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
+          height = 0;
+          segment.hasLabel = true;
+          previousLabeledSegment = segment;
+        }
+        if (i !== 1 && segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
+          segment.hasDot = true;
+          dotHeight = 0;
+        }
+
+        height += segment.height;
+        dotHeight += segment.height;
+      }
+      segments.push(segment);
+    }
+
+    hoverLabel = segments[0]?.dateFormatted;
+    return segments;
+  };
+
+  const updateLabel = (segment: HTMLElement) => {
+    hoverLabel = segment.dataset.label;
+    bucketDate = segment.dataset.timeSegmentBucketDate;
+  };
+
+  const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
+    const wasDragging = isDragging;
+
+    isDragging = event.isDragging ?? isDragging;
+    clientY = event.clientY;
+
+    if (!scrollBar) {
+      return;
+    }
+
+    const rect = scrollBar.getBoundingClientRect()!;
+    const lower = 0;
+    const upper = rect?.height - HOVER_DATE_HEIGHT * 2;
+    hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper);
+    const x = rect!.left + rect!.width / 2;
+    const elems = document.elementsFromPoint(x, clientY);
+    const segment = elems.find(({ id }) => id === 'time-segment');
+    let bucketPercentY = 0;
+    if (segment) {
+      updateLabel(segment as HTMLElement);
+      const sr = segment.getBoundingClientRect();
+      const sy = sr.y;
+      const relativeY = clientY - sy;
+      bucketPercentY = relativeY / sr.height;
+    } else {
+      const leadin = elems.find(({ id }) => id === 'lead-in');
+      if (leadin) {
+        updateLabel(leadin as HTMLElement);
+      } else {
+        bucketDate = undefined;
+        bucketPercentY = 0;
+      }
+    }
+
+    const scrollPercent = toTimelineY(hoverY);
+    if (wasDragging === false && isDragging) {
+      void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
+      void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
+    }
+
+    if (wasDragging && !isDragging) {
+      void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
+      return;
+    }
+
+    if (!isDragging) {
+      return;
+    }
+
+    void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
+  };
+</script>
+
+<svelte:window
+  bind:innerHeight={windowHeight}
+  on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
+  on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
+  on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
+/>
+
+<!-- svelte-ignore a11y-no-static-element-interactions -->
+
+<div
+  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}
+  on:mouseenter={() => (isHover = true)}
+  on:mouseleave={() => (isHover = false)}
+>
+  {#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"
+      style:top="{hoverY + 2}px"
+    >
+      {hoverLabel}
+    </div>
+  {/if}
+  <!-- Scroll Position Indicator Line -->
+  {#if !isDragging}
+    <div
+      class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
+      style:top="{scrollY + HOVER_DATE_HEIGHT}px"
+    />
+  {/if}
+  <div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}>
+    {#if relativeTopOffset > 6}
+      <div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300" />
+    {/if}
+  </div>
+  <!-- Time Segment -->
+  {#each segments as segment}
+    <div
+      id="time-segment"
+      class="relative"
+      data-time-segment-bucket-date={segment.date}
+      data-label={segment.dateFormatted}
+      style:height={segment.height + 'px'}
+      aria-label={segment.dateFormatted + ' ' + segment.count}
+    >
+      {#if segment.hasLabel}
+        <div
+          aria-label={segment.dateFormatted + ' ' + segment.count}
+          class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono"
+        >
+          {segment.date.year}
+        </div>
+      {/if}
+      {#if segment.hasDot}
+        <div
+          aria-label={segment.dateFormatted + ' ' + segment.count}
+          class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"
+        />
+      {/if}
+    </div>
+  {/each}
+  <div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
+</div>
+
+<style>
+  #immich-scrubbable-scrollbar,
+  #time-segment {
+    contain: layout size style;
+  }
+</style>
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 fcf68fdb91..2f1efc487c 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
@@ -4,7 +4,8 @@
   import Portal from '$lib/components/shared-components/portal/portal.svelte';
   import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  import { suggestDuplicateByFileSize } from '$lib/utils';
+  import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils';
+  import { navigate } from '$lib/utils/navigation';
   import { shortcuts } from '$lib/actions/shortcut';
   import { type AssetResponseDto } from '@immich/sdk';
   import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
@@ -158,7 +159,10 @@
           const index = getAssetIndex($viewingAsset.id) - 1 + assets.length;
           setAsset(assets[index % assets.length]);
         }}
-        on:close={() => assetViewingStore.showAssetViewer(false)}
+        on:close={() => {
+          assetViewingStore.showAssetViewer(false);
+          handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
+        }}
       />
     </Portal>
   {/await}
diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts
index cabe2e85a1..2e6e44511d 100644
--- a/web/src/lib/stores/asset-viewing.store.ts
+++ b/web/src/lib/stores/asset-viewing.store.ts
@@ -1,4 +1,5 @@
 import { getKey } from '$lib/utils';
+import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
 import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
 import { readonly, writable } from 'svelte/store';
 
@@ -6,6 +7,7 @@ function createAssetViewingStore() {
   const viewingAssetStoreState = writable<AssetResponseDto>();
   const preloadAssets = writable<AssetResponseDto[]>([]);
   const viewState = writable<boolean>(false);
+  const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
 
   const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => {
     preloadAssets.set(assetsToPreload);
@@ -26,6 +28,7 @@ function createAssetViewingStore() {
     asset: readonly(viewingAssetStoreState),
     preloadAssets: readonly(preloadAssets),
     isViewing: viewState,
+    gridScrollTarget,
     setAsset,
     setAssetId,
     showAssetViewer,
diff --git a/web/src/lib/stores/asset.store.spec.ts b/web/src/lib/stores/asset.store.spec.ts
index 3fd9e1e981..7787bf794d 100644
--- a/web/src/lib/stores/asset.store.spec.ts
+++ b/web/src/lib/stores/asset.store.spec.ts
@@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
 import { AbortError } from '$lib/utils';
 import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
 import { assetFactory } from '@test-data/factories/asset-factory';
-import { AssetStore, BucketPosition } from './assets.store';
+import { AssetStore } from './assets.store';
 
 describe('AssetStore', () => {
   beforeEach(() => {
@@ -26,7 +26,8 @@ describe('AssetStore', () => {
       ]);
       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
 
-      await assetStore.init({ width: 1588, height: 1000 });
+      await assetStore.init();
+      await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
     it('should load buckets in viewport', () => {
@@ -38,15 +39,15 @@ describe('AssetStore', () => {
     it('calculates bucket height', () => {
       expect(assetStore.buckets).toEqual(
         expect.arrayContaining([
-          expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }),
-          expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }),
-          expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }),
+          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-01-01T00:00:00.000Z', bucketHeight: 286 }),
         ]),
       );
     });
 
     it('calculates timeline height', () => {
-      expect(assetStore.timelineHeight).toBe(4230);
+      expect(assetStore.timelineHeight).toBe(4383);
     });
   });
 
@@ -72,35 +73,28 @@ describe('AssetStore', () => {
 
         return bucketAssets[timeBucket];
       });
-      await assetStore.init({ width: 0, height: 0 });
+      await assetStore.init();
+      await assetStore.updateViewport({ width: 0, height: 0 });
     });
 
     it('loads a bucket', async () => {
       expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
-      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
+      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);
     });
 
     it('ignores invalid buckets', async () => {
-      await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible);
+      await assetStore.loadBucket('2023-01-01T00:00:00.000Z');
       expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
     });
 
-    it('only updates the position of loaded buckets', async () => {
-      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);
-      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown);
-
-      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
-      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible);
-    });
-
     it('cancels bucket loading', async () => {
       const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
-      const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown);
+      const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
 
       const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort');
-      assetStore.cancelBucket(bucket!);
+      bucket?.cancel();
       expect(abortSpy).toBeCalledTimes(1);
 
       await loadPromise;
@@ -109,24 +103,24 @@ describe('AssetStore', () => {
 
     it('prevents loading buckets multiple times', async () => {
       await Promise.all([
-        assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown),
-        assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown),
+        assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
+        assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
       ]);
       expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
 
-      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);
+      await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
       expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
     });
 
     it('allows loading a canceled bucket', async () => {
       const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
-      const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown);
+      const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
 
-      assetStore.cancelBucket(bucket!);
+      bucket?.cancel();
       await loadPromise;
       expect(bucket?.assets.length).toEqual(0);
 
-      await assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown);
+      await assetStore.loadBucket(bucket!.bucketDate);
       expect(bucket!.assets.length).toEqual(3);
     });
   });
@@ -137,7 +131,8 @@ describe('AssetStore', () => {
     beforeEach(async () => {
       assetStore = new AssetStore({});
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init({ width: 1588, height: 1000 });
+      await assetStore.init();
+      await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
     it('is empty initially', () => {
@@ -219,7 +214,8 @@ describe('AssetStore', () => {
     beforeEach(async () => {
       assetStore = new AssetStore({});
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init({ width: 1588, height: 1000 });
+      await assetStore.init();
+      await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
     it('ignores non-existing assets', () => {
@@ -263,7 +259,8 @@ describe('AssetStore', () => {
     beforeEach(async () => {
       assetStore = new AssetStore({});
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init({ width: 1588, height: 1000 });
+      await assetStore.init();
+      await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
     it('ignores invalid IDs', () => {
@@ -312,7 +309,8 @@ describe('AssetStore', () => {
       ]);
       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
 
-      await assetStore.init({ width: 0, height: 0 });
+      await assetStore.init();
+      await assetStore.updateViewport({ width: 0, height: 0 });
     });
 
     it('returns null for invalid assetId', async () => {
@@ -321,15 +319,15 @@ describe('AssetStore', () => {
     });
 
     it('returns previous assetId', async () => {
-      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
+      await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
       const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
 
       expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]);
     });
 
     it('returns previous assetId spanning multiple buckets', async () => {
-      await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
-      await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
+      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');
@@ -337,7 +335,7 @@ describe('AssetStore', () => {
     });
 
     it('loads previous bucket', async () => {
-      await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
+      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');
@@ -347,9 +345,9 @@ describe('AssetStore', () => {
     });
 
     it('skips removed assets', async () => {
-      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
-      await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
-      await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
+      await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
+      await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
+      await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
 
       const [assetOne, assetTwo, assetThree] = assetStore.assets;
       assetStore.removeAssets([assetTwo.id]);
@@ -357,7 +355,7 @@ describe('AssetStore', () => {
     });
 
     it('returns null when no more assets', async () => {
-      await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
+      await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
       expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull();
     });
   });
@@ -368,7 +366,8 @@ describe('AssetStore', () => {
     beforeEach(async () => {
       assetStore = new AssetStore({});
       sdkMock.getTimeBuckets.mockResolvedValue([]);
-      await assetStore.init({ width: 0, height: 0 });
+      await assetStore.init();
+      await assetStore.updateViewport({ width: 0, height: 0 });
     });
 
     it('returns null for invalid buckets', () => {
diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts
index 1022729e91..7fd82b4c3a 100644
--- a/web/src/lib/stores/assets.store.ts
+++ b/web/src/lib/stores/assets.store.ts
@@ -1,6 +1,11 @@
+import { locale } from '$lib/stores/preferences.store';
 import { getKey } from '$lib/utils';
-import { fromLocalDateTime } from '$lib/utils/timeline-util';
-import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
+import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
+import { getAssetRatio } from '$lib/utils/asset-utils';
+import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
+import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
+import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
+import createJustifiedLayout from 'justified-layout';
 import { throttle } from 'lodash-es';
 import { DateTime } from 'luxon';
 import { t } from 'svelte-i18n';
@@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store';
 import { handleError } from '../utils/handle-error';
 import { websocketEvents } from './websocket';
 
-export enum BucketPosition {
-  Above = 'above',
-  Below = 'below',
-  Visible = 'visible',
-  Unknown = 'unknown',
-}
 type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
 export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;
 
+const LAYOUT_OPTIONS = {
+  boxSpacing: 2,
+  containerPadding: 0,
+  targetRowHeightTolerance: 0.15,
+  targetRowHeight: 235,
+};
+
 export interface Viewport {
   width: number;
   height: number;
 }
+export type ViewportXY = Viewport & {
+  x: number;
+  y: number;
+};
 
 interface AssetLookup {
   bucket: AssetBucket;
@@ -29,16 +39,89 @@ interface AssetLookup {
 }
 
 export class AssetBucket {
+  store!: AssetStore;
+  bucketDate!: string;
   /**
    * 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
    */
-  bucketHeight!: number;
-  bucketDate!: string;
-  bucketCount!: number;
-  assets!: AssetResponseDto[];
-  cancelToken!: AbortController | null;
-  position!: BucketPosition;
+  bucketHeight: number = 0;
+  isBucketHeightActual: boolean = false;
+  bucketDateFormattted!: string;
+  bucketCount: number = 0;
+  assets: AssetResponseDto[] = [];
+  dateGroups: DateGroup[] = [];
+  cancelToken: AbortController | undefined;
+  /**
+   * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset.
+   */
+  isPreventCancel: boolean = false;
+  /**
+   * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
+   */
+  complete!: Promise<void>;
+  loading: boolean = false;
+  isLoaded: boolean = false;
+  intersecting: boolean = false;
+  measured: boolean = false;
+  measuredPromise!: Promise<void>;
+
+  constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) {
+    Object.assign(this, props);
+    this.init();
+  }
+
+  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((resolve, reject) => {
+      this.loadedSignal = resolve;
+      this.canceledSignal = reject;
+    });
+    // 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.
+    this.complete.catch(() => 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',
+      });
+  }
+
+  private loadedSignal: (() => void) | undefined;
+  private canceledSignal: (() => void) | undefined;
+  measuredSignal: (() => void) | undefined;
+
+  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();
+  }
 }
 
 const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
@@ -65,34 +148,101 @@ interface TrashAssets {
   type: 'trash';
   values: string[];
 }
+interface UpdateStackAssets {
+  type: 'update_stack_assets';
+  values: string[];
+}
 
 export const photoViewer = writable<HTMLImageElement | null>(null);
 
-type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets;
+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 class AssetStore {
-  private store$ = writable(this);
   private assetToBucket: Record<string, AssetLookup> = {};
   private pendingChanges: PendingChange[] = [];
   private unsubscribers: Unsubscriber[] = [];
   private options: AssetApiGetTimeBucketsRequest;
+  private viewport: Viewport = {
+    height: 0,
+    width: 0,
+  };
+  private initializedSignal!: () => void;
+  private store$ = writable(this);
 
+  lastScrollTime: number = 0;
+  subscribe = this.store$.subscribe;
+  /**
+   * A promise that resolves once the store is initialized.
+   */
+  taskManager = new AssetGridTaskManager(this);
+  complete!: Promise<void>;
   initialized = false;
   timelineHeight = 0;
   buckets: AssetBucket[] = [];
   assets: AssetResponseDto[] = [];
   albumAssets: Set<string> = new Set();
+  pendingScrollBucket: AssetBucket | undefined;
+  pendingScrollAssetId: string | undefined;
+
+  listeners: BucketListener[] = [];
 
   constructor(
     options: AssetStoreOptions,
     private albumId?: string,
   ) {
     this.options = { ...options, size: TimeBucketSize.Month };
+    // create a promise, and store its resolve callbacks. The initializedSignal callback
+    // will be invoked when a the assetstore is initialized.
+    this.complete = new Promise((resolve) => {
+      this.initializedSignal = resolve;
+    });
     this.store$.set(this);
   }
 
-  subscribe = this.store$.subscribe;
-
   private addPendingChanges(...changes: PendingChange[]) {
     // prevent websocket events from happening before local client events
     setTimeout(() => {
@@ -182,8 +332,35 @@ export class AssetStore {
     this.emit(true);
   }, 2500);
 
-  async init(viewport: Viewport) {
-    this.initialized = false;
+  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 (bucketListener) {
+      this.addListener(bucketListener);
+    }
+    //  uncaught rejection go away
+    this.complete.catch(() => void 0);
     this.timelineHeight = 0;
     this.buckets = [];
     this.assets = [];
@@ -194,65 +371,118 @@ export class AssetStore {
       ...this.options,
       key: getKey(),
     });
-
+    this.buckets = timebuckets.map(
+      (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }),
+    );
+    this.initializedSignal();
     this.initialized = true;
-
-    this.buckets = timebuckets.map((bucket) => ({
-      bucketDate: bucket.timeBucket,
-      bucketHeight: 0,
-      bucketCount: bucket.count,
-      assets: [],
-      cancelToken: null,
-      position: BucketPosition.Unknown,
-    }));
-
-    // if loading an asset, the grid-view may be hidden, which means
-    // it has 0 width and height. No need to update bucket or timeline
-    // heights in this case. Later, updateViewport will be called to
-    // update the heights.
-    if (viewport.height !== 0 && viewport.width !== 0) {
-      await this.updateViewport(viewport);
-    }
   }
 
-  async updateViewport(viewport: Viewport) {
+  public destroy() {
+    this.taskManager.destroy();
+    this.listeners = [];
+    this.initialized = false;
+  }
+
+  async updateViewport(viewport: Viewport, force?: boolean) {
+    if (!this.initialized) {
+      return;
+    }
+    if (viewport.height === 0 && viewport.width === 0) {
+      return;
+    }
+
+    if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) {
+      return;
+    }
+
+    // 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 };
+
     for (const bucket of this.buckets) {
-      const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
-      const rows = Math.ceil(unwrappedWidth / viewport.width);
-      const height = rows * THUMBNAIL_HEIGHT;
-      bucket.bucketHeight = height;
+      this.updateGeometry(bucket, changedWidth);
     }
     this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
 
-    let height = 0;
     const loaders = [];
+    let height = 0;
     for (const bucket of this.buckets) {
-      if (height < viewport.height) {
-        height += bucket.bucketHeight;
-        loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible));
-        continue;
+      if (height >= viewport.height) {
+        break;
       }
-      break;
+      height += bucket.bucketHeight;
+      loaders.push(this.loadBucket(bucket.bucketDate));
     }
     await Promise.all(loaders);
+    this.notifyListeners({ type: 'viewport' });
     this.emit(false);
   }
 
-  async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> {
+  private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
+    if (invalidateHeight) {
+      bucket.isBucketHeightActual = false;
+      bucket.measured = false;
+      for (const assetGroup of bucket.dateGroups) {
+        assetGroup.heightActual = false;
+      }
+    }
+    if (!bucket.isBucketHeightActual) {
+      const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
+      const rows = Math.ceil(unwrappedWidth / this.viewport.width);
+      const height = 51 + rows * THUMBNAIL_HEIGHT;
+      bucket.bucketHeight = height;
+    }
+
+    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;
+      }
+
+      const layoutResult = createJustifiedLayout(
+        assetGroup.assets.map((g) => getAssetRatio(g)),
+        {
+          ...LAYOUT_OPTIONS,
+          containerWidth: Math.floor(this.viewport.width),
+        },
+      );
+      assetGroup.geometry = {
+        ...layoutResult,
+        containerWidth: calculateWidth(layoutResult.boxes),
+      };
+    }
+  }
+
+  async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise<void> {
     const bucket = this.getBucketByDate(bucketDate);
     if (!bucket) {
       return;
     }
-
-    bucket.position = position;
-
-    if (bucket.cancelToken || bucket.assets.length > 0) {
-      this.emit(false);
+    if (bucket.bucketCount === bucket.assets.length) {
+      // already loaded
       return;
     }
 
-    bucket.cancelToken = new AbortController();
+    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 assets = await getTimeBucket(
         {
@@ -260,9 +490,14 @@ export class AssetStore {
           timeBucket: bucketDate,
           key: getKey(),
         },
-        { signal: bucket.cancelToken.signal },
+        { signal: cancelToken.signal },
       );
 
+      if (cancelToken.signal.aborted) {
+        this.notifyListeners({ type: 'cancel', bucket });
+        return;
+      }
+
       if (this.albumId) {
         const albumAssets = await getTimeBucket(
           {
@@ -271,50 +506,87 @@ export class AssetStore {
             size: this.options.size,
             key: getKey(),
           },
-          { signal: bucket.cancelToken.signal },
+          { signal: cancelToken.signal },
         );
-
+        if (cancelToken.signal.aborted) {
+          this.notifyListeners({ type: 'cancel', bucket });
+          return;
+        }
         for (const asset of albumAssets) {
           this.albumAssets.add(asset.id);
         }
       }
 
-      if (bucket.cancelToken.signal.aborted) {
+      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;
       }
-
-      bucket.assets = assets;
-
-      this.emit(true);
-    } catch (error) {
       const $t = get(t);
       handleError(error, $t('errors.failed_to_load_assets'));
+      bucket.errored();
     } finally {
-      bucket.cancelToken = null;
+      bucket.cancelToken = undefined;
+      this.emit(true);
     }
   }
 
-  cancelBucket(bucket: AssetBucket) {
-    bucket.cancelToken?.abort();
-  }
-
-  updateBucket(bucketDate: string, height: number) {
+  updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
     const bucket = this.getBucketByDate(bucketDate);
     if (!bucket) {
-      return 0;
+      return {};
+    }
+    let delta = 0;
+    if ('height' in properties) {
+      const height = properties.height!;
+      delta = height - bucket.bucketHeight;
+      bucket.isBucketHeightActual = true;
+      bucket.bucketHeight = height;
+      this.timelineHeight += delta;
+      this.notifyListeners({ type: 'bucket-height', bucket, delta });
+    }
+    if ('intersecting' in properties) {
+      bucket.intersecting = properties.intersecting!;
+    }
+    if ('measured' in properties) {
+      if (properties.measured) {
+        bucket.measuredSignal?.();
+      }
+      bucket.measured = properties.measured!;
     }
-
-    const delta = height - bucket.bucketHeight;
-    const scrollTimeline = bucket.position == BucketPosition.Above;
-
-    bucket.bucketHeight = height;
-    bucket.position = BucketPosition.Unknown;
-
-    this.timelineHeight += delta;
-
     this.emit(false);
+    return { delta };
+  }
 
-    return scrollTimeline ? delta : 0;
+  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 });
+      }
+    }
+    this.emit(false);
+    return { delta };
   }
 
   addAssets(assets: AssetResponseDto[]) {
@@ -354,15 +626,7 @@ export class AssetStore {
       let bucket = this.getBucketByDate(timeBucket);
 
       if (!bucket) {
-        bucket = {
-          bucketDate: timeBucket,
-          bucketHeight: THUMBNAIL_HEIGHT,
-          bucketCount: 0,
-          assets: [],
-          cancelToken: null,
-          position: BucketPosition.Unknown,
-        };
-
+        bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT });
         this.buckets.push(bucket);
       }
 
@@ -383,6 +647,8 @@ export class AssetStore {
         const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC();
         return bDate.diff(aDate).milliseconds;
       });
+      bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
+      this.updateGeometry(bucket, true);
     }
 
     this.emit(true);
@@ -392,18 +658,73 @@ export class AssetStore {
     return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
   }
 
-  async getBucketInfoForAssetId({ id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>) {
+  async findAndLoadBucketAsPending(id: string) {
     const bucketInfo = this.assetToBucket[id];
     if (bucketInfo) {
-      return bucketInfo;
+      const bucket = bucketInfo.bucket;
+      this.pendingScrollBucket = bucket;
+      this.pendingScrollAssetId = id;
+      this.emit(false);
+      return bucket;
     }
+    const asset = await getAssetInfo({ id });
+    if (asset) {
+      if (this.options.isArchived !== asset.isArchived) {
+        return;
+      }
+      const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
+      if (bucket) {
+        this.pendingScrollBucket = bucket;
+        this.pendingScrollAssetId = asset.id;
+        this.emit(false);
+      }
+      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 }) {
     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 });
     }
-    await this.loadBucket(date.toISO()!, BucketPosition.Unknown);
+    const iso = date.toISO()!;
+    await this.loadBucket(iso, options);
+    return this.getBucketByDate(iso);
+  }
+
+  private async getBucketInfoForAsset(
+    { id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>,
+    options: { preventCancel?: boolean; pending?: boolean } = {},
+  ) {
+    const bucketInfo = this.assetToBucket[id];
+    if (bucketInfo) {
+      return bucketInfo;
+    }
+    await this.loadBucketAtTime(localDateTime, options);
     return this.assetToBucket[id] || null;
   }
 
@@ -417,7 +738,7 @@ export class AssetStore {
     );
     for (const bucket of this.buckets) {
       if (index < bucket.bucketCount) {
-        await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
+        await this.loadBucket(bucket.bucketDate);
         return bucket.assets[index] || null;
       }
 
@@ -458,6 +779,7 @@ export class AssetStore {
     // 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)) {
@@ -465,17 +787,22 @@ export class AssetStore {
         }
 
         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);
+      }
     }
 
     this.emit(true);
   }
 
   async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
-    const info = await this.getBucketInfoForAssetId(asset);
+    const info = await this.getBucketInfoForAsset(asset);
     if (!info) {
       return null;
     }
@@ -491,12 +818,12 @@ export class AssetStore {
     }
 
     const previousBucket = this.buckets[bucketIndex - 1];
-    await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown);
+    await this.loadBucket(previousBucket.bucketDate);
     return previousBucket.assets.at(-1) || null;
   }
 
   async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
-    const info = await this.getBucketInfoForAssetId(asset);
+    const info = await this.getBucketInfoForAsset(asset);
     if (!info) {
       return null;
     }
@@ -512,7 +839,7 @@ export class AssetStore {
     }
 
     const nextBucket = this.buckets[bucketIndex + 1];
-    await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown);
+    await this.loadBucket(nextBucket.bucketDate);
     return nextBucket.assets[0] || null;
   }
 
@@ -537,8 +864,7 @@ export class AssetStore {
       }
       this.assetToBucket = assetToBucket;
     }
-
-    this.store$.update(() => this);
+    this.store$.set(this);
   }
 }
 
diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts
new file mode 100644
index 0000000000..6ece1327c4
--- /dev/null
+++ b/web/src/lib/utils/asset-store-task-manager.ts
@@ -0,0 +1,465 @@
+import type { AssetBucket, AssetStore } from '$lib/stores/assets.store';
+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);
+  }
+
+  seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
+    const bucketTask = this.getOrCreateBucketTask(bucket);
+    bucketTask.scheduleSeparated(componentId, seperated);
+  }
+
+  intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
+    const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
+    bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
+  }
+
+  seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
+    const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
+    bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
+  }
+
+  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);
+  }
+
+  seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
+    const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
+    const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
+    dateGroupTask.separatedThumbnail(componentId, asset, seperated);
+  }
+}
+
+class IntersectionTask {
+  internalTaskManager: InternalTaskManager;
+  seperatedKey;
+  intersectedKey;
+  priority;
+
+  intersected: Task | undefined;
+  separated: Task | undefined;
+
+  constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
+    this.internalTaskManager = internalTaskManager;
+    this.seperatedKey = 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 };
+  }
+
+  trackSeperatedTask(componentId: string, task: Task) {
+    const execTask = () => {
+      if (this.intersected) {
+        return;
+      }
+      task?.();
+    };
+    this.separated = execTask;
+    const cleanup = () => {
+      this.separated = undefined;
+      this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
+    };
+    return { task: execTask, cleanup };
+  }
+
+  removePendingSeparated() {
+    if (this.separated) {
+      this.internalTaskManager.removeSeparateTask(this.seperatedKey);
+    }
+  }
+  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: componentId,
+      priority: this.priority,
+      taskId: this.intersectedKey,
+    });
+  }
+
+  scheduleSeparated(componentId: string, separated: Task) {
+    this.removePendingIntersected();
+
+    if (this.separated) {
+      return;
+    }
+
+    const { task, cleanup } = this.trackSeperatedTask(componentId, separated);
+    this.internalTaskManager.queueSeparateTask({
+      task,
+      cleanup,
+      componentId: componentId,
+      taskId: this.seperatedKey,
+    });
+  }
+}
+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, seperated: Task) {
+    const thumbnailTask = this.getOrCreateThumbnailTask(asset);
+    thumbnailTask.scheduleSeparated(componentId, seperated);
+  }
+}
+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 2722745317..576b14b201 100644
--- a/web/src/lib/utils/asset-utils.ts
+++ b/web/src/lib/utils/asset-utils.ts
@@ -4,7 +4,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
 import { AppRoute } from '$lib/constants';
 import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
 import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
+import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
 import { downloadManager } from '$lib/stores/download';
 import { preferences } from '$lib/stores/user.store';
 import { downloadRequest, getKey, withError } from '$lib/utils';
@@ -403,7 +403,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt
 
   try {
     for (const bucket of assetStore.buckets) {
-      await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
+      await assetStore.loadBucket(bucket.bucketDate);
 
       if (!get(isSelectingAllAssets)) {
         break; // Cancelled
diff --git a/web/src/lib/utils/idle-callback-support.ts b/web/src/lib/utils/idle-callback-support.ts
new file mode 100644
index 0000000000..0f7f060084
--- /dev/null
+++ b/web/src/lib/utils/idle-callback-support.ts
@@ -0,0 +1,20 @@
+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 = window.requestIdleCallback || fake_requestIdleCallback;
+export const cancelIdleCB = window.cancelIdleCallback || fake_cancelIdleCallback;
diff --git a/web/src/lib/utils/keyed-priority-queue.ts b/web/src/lib/utils/keyed-priority-queue.ts
new file mode 100644
index 0000000000..2483b22c6d
--- /dev/null
+++ b/web/src/lib/utils/keyed-priority-queue.ts
@@ -0,0 +1,50 @@
+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 >= 0) {
+        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/navigation.ts b/web/src/lib/utils/navigation.ts
index 4d5660f173..304376b347 100644
--- a/web/src/lib/utils/navigation.ts
+++ b/web/src/lib/utils/navigation.ts
@@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk';
 import type { NavigationTarget } from '@sveltejs/kit';
 import { get } from 'svelte/store';
 
+export type AssetGridRouteSearchParams = {
+  at: string | null | undefined;
+};
 export const isExternalUrl = (url: string): boolean => {
   return new URL(url, window.location.href).origin !== window.location.origin;
 };
@@ -33,17 +36,38 @@ function currentUrlWithoutAsset() {
 
 export function currentUrlReplaceAssetId(assetId: string) {
   const $page = get(page);
+  const params = new URLSearchParams($page.url.search);
+  // always remove the assetGridScrollTargetParams
+  params.delete('at');
+  const searchparams = params.size > 0 ? '?' + params.toString() : '';
   // this contains special casing for the /photos/:assetId photos route, which hangs directly
   // off / instead of a subpath, unlike every other asset-containing route.
   return isPhotosRoute($page.route.id)
-    ? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}`
-    : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`;
+    ? `${AppRoute.PHOTOS}/${assetId}${searchparams}`
+    : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`;
+}
+
+function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) {
+  const $page = get(page);
+  const parsed = new URL(url, $page.url);
+
+  const { at: assetId } = searchParams || { at: null };
+
+  if (!assetId) {
+    return parsed.pathname;
+  }
+
+  const params = new URLSearchParams($page.url.search);
+  if (assetId) {
+    params.set('at', assetId);
+  }
+  return parsed.pathname + '?' + params.toString();
 }
 
 function currentUrl() {
   const $page = get(page);
   const current = $page.url;
-  return current.pathname + current.search;
+  return current.pathname + current.search + current.hash;
 }
 
 interface Route {
@@ -55,24 +79,58 @@ interface Route {
 
 interface AssetRoute extends Route {
   targetRoute: 'current';
-  assetId: string | null;
+  assetId: string | null | undefined;
 }
+interface AssetGridRoute extends Route {
+  targetRoute: 'current';
+  assetId: string | null | undefined;
+  assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined;
+}
+
+type ImmichRoute = AssetRoute | AssetGridRoute;
+
+type NavOptions = {
+  /* navigate even if url is the same */
+  forceNavigate?: boolean | undefined;
+  replaceState?: boolean | undefined;
+  noScroll?: boolean | undefined;
+  keepFocus?: boolean | undefined;
+  invalidateAll?: boolean | undefined;
+  state?: App.PageState | undefined;
+};
 
 function isAssetRoute(route: Route): route is AssetRoute {
   return route.targetRoute === 'current' && 'assetId' in route;
 }
 
-async function navigateAssetRoute(route: AssetRoute) {
+function isAssetGridRoute(route: Route): route is AssetGridRoute {
+  return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route;
+}
+
+async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) {
   const { assetId } = route;
   const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
-  if (next !== currentUrl()) {
-    await goto(next, { replaceState: false });
+  const current = currentUrl();
+  if (next !== current || options?.forceNavigate) {
+    await goto(next, options);
   }
 }
 
-export function navigate<T extends Route>(change: T): Promise<void> {
-  if (isAssetRoute(change)) {
-    return navigateAssetRoute(change);
+async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) {
+  const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route;
+  const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
+  const next = replaceScrollTarget(assetUrl, assetGridScrollTarget);
+  const current = currentUrl();
+  if (next !== current || options?.forceNavigate) {
+    await goto(next, options);
+  }
+}
+
+export function navigate(change: ImmichRoute, options?: NavOptions): Promise<void> {
+  if (isAssetGridRoute(change)) {
+    return navigateAssetGridRoute(change, options);
+  } else if (isAssetRoute(change)) {
+    return navigateAssetRoute(change, options);
   }
   // future navigation requests here
   throw `Invalid navigation: ${JSON.stringify(change)}`;
diff --git a/web/src/lib/utils/priority-queue.ts b/web/src/lib/utils/priority-queue.ts
new file mode 100644
index 0000000000..6b08ffe7ad
--- /dev/null
+++ b/web/src/lib/utils/priority-queue.ts
@@ -0,0 +1,21 @@
+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 76a0d1b5cb..3a8f66ee08 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -1,9 +1,38 @@
+import type { AssetBucket } from '$lib/stores/assets.store';
 import { locale } from '$lib/stores/preferences.store';
 import type { AssetResponseDto } from '@immich/sdk';
-import { groupBy, sortBy } from 'lodash-es';
+import type createJustifiedLayout from 'justified-layout';
+import { groupBy, memoize, sortBy } from 'lodash-es';
 import { DateTime } from 'luxon';
 import { get } from 'svelte/store';
 
+export type DateGroup = {
+  date: DateTime;
+  groupTitle: string;
+  assets: AssetResponseDto[];
+  height: number;
+  heightActual: boolean;
+  intersecting: boolean;
+  geometry: Geometry;
+  bucket: AssetBucket;
+};
+export type ScrubberListener = (
+  bucketDate: string | undefined,
+  overallScrollPercent: number,
+  bucketScrollPercent: number,
+) => void | Promise<void>;
+export type ScrollTargetListener = ({
+  bucket,
+  dateGroup,
+  asset,
+  offset,
+}: {
+  bucket: AssetBucket;
+  dateGroup: DateGroup;
+  asset: AssetResponseDto;
+  offset: number;
+}) => void;
+
 export const fromLocalDateTime = (localDateTime: string) =>
   DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
 
@@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string {
   return date.toLocaleString(groupDateFormat);
 }
 
-export function splitBucketIntoDateGroups(
-  assets: AssetResponseDto[],
-  locale: string | undefined,
-): AssetResponseDto[][] {
-  const grouped = groupBy(assets, (asset) =>
+type Geometry = ReturnType<typeof createJustifiedLayout> & {
+  containerWidth: number;
+};
+
+function emptyGeometry() {
+  return {
+    containerWidth: 0,
+    containerHeight: 0,
+    widowCount: 0,
+    boxes: [],
+  };
+}
+
+const formatDateGroupTitle = memoize(formatGroupTitle);
+
+export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
+  const grouped = groupBy(bucket.assets, (asset) =>
     fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }),
   );
-  return sortBy(grouped, (group) => assets.indexOf(group[0]));
+  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: bucket,
+    };
+  });
 }
 
 export type LayoutBox = {
+  aspectRatio: number;
   top: number;
-  left: number;
   width: number;
+  height: number;
+  left: number;
+  forcedAspectRatio?: boolean;
 };
 
 export function calculateWidth(boxes: LayoutBox[]): number {
@@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number {
       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;
+}
diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts
new file mode 100644
index 0000000000..e21c30de77
--- /dev/null
+++ b/web/src/lib/utils/tunables.ts
@@ -0,0 +1,63 @@
+function getBoolean(string: string | null, fallback: boolean) {
+  if (string === null) {
+    return fallback;
+  }
+  return 'true' === string;
+}
+function getNumber(string: string | null, fallback: number) {
+  if (string === null) {
+    return fallback;
+  }
+  return Number.parseInt(string);
+}
+function getFloat(string: string | null, fallback: number) {
+  if (string === null) {
+    return fallback;
+  }
+  return Number.parseFloat(string);
+}
+export const TUNABLES = {
+  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),
+  },
+  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)/+layout.svelte b/web/src/routes/(user)/+layout.svelte
index 23f38b86f4..bf24d0e7e4 100644
--- a/web/src/routes/(user)/+layout.svelte
+++ b/web/src/routes/(user)/+layout.svelte
@@ -1,10 +1,10 @@
 <script lang="ts">
   import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
   import { page } from '$app/stores';
-  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
 
-  // This block takes care of opening the viewer.
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
+
   // $page.data.asset is loaded by route specific +page.ts loaders if that
   // route contains the assetId path.
   $: {
@@ -13,6 +13,8 @@
     } else {
       $showAssetViewer = false;
     }
+    const asset = $page.url.searchParams.get('at');
+    $gridScrollTarget = { at: asset };
   }
 </script>
 
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 9e670f714c..ff5709df99 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
@@ -43,7 +43,13 @@
   import { downloadAlbum } from '$lib/utils/asset-utils';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
   import { handleError } from '$lib/utils/handle-error';
-  import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
+  import {
+    isAlbumsRoute,
+    isPeopleRoute,
+    isSearchRoute,
+    navigate,
+    type AssetGridRouteSearchParams,
+  } from '$lib/utils/navigation';
   import {
     AlbumUserRole,
     AssetOrder,
@@ -78,12 +84,15 @@
   import type { PageData } from './$types';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
+  import { onDestroy } from 'svelte';
 
   export let data: PageData;
 
-  let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
+  let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
   let { slideshowState, slideshowNavigation } = slideshowStore;
 
+  let oldAt: AssetGridRouteSearchParams | null | undefined;
+
   $: album = data.album;
   $: albumId = album.id;
   $: albumKey = `${albumId}_${albumOrder}`;
@@ -244,7 +253,7 @@
     }
 
     if (viewMode === ViewMode.SELECT_ASSETS) {
-      handleCloseSelectAssets();
+      await handleCloseSelectAssets();
       return;
     }
     if (viewMode === ViewMode.LINK_SHARING) {
@@ -289,20 +298,37 @@
 
       timelineInteractionStore.clearMultiselect();
       viewMode = ViewMode.VIEW;
+      await navigate(
+        { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
+        { replaceState: true, forceNavigate: true },
+      );
     } catch (error) {
       handleError(error, $t('errors.error_adding_assets_to_album'));
     }
   };
 
-  const handleCloseSelectAssets = () => {
+  const setModeToView = async () => {
     viewMode = ViewMode.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 },
+    );
+    oldAt = null;
+  };
+
+  const handleCloseSelectAssets = async () => {
     timelineInteractionStore.clearMultiselect();
+    await setModeToView();
   };
 
   const handleSelectFromComputer = async () => {
     await openFileUploadDialog({ albumId: album.id });
     timelineInteractionStore.clearMultiselect();
-    viewMode = ViewMode.VIEW;
+    await setModeToView();
   };
 
   const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
@@ -400,6 +426,11 @@
       await deleteAlbum(album);
     }
   });
+
+  onDestroy(() => {
+    assetStore.destroy();
+    timelineStore.destroy();
+  });
 </script>
 
 <div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
@@ -444,7 +475,14 @@
             {#if isEditor}
               <CircleIconButton
                 title={$t('add_photos')}
-                on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
+                on:click={async () => {
+                  viewMode = ViewMode.SELECT_ASSETS;
+                  oldAt = { at: $gridScrollTarget?.at };
+                  await navigate(
+                    { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
+                    { replaceState: true },
+                  );
+                }}
                 icon={mdiImagePlusOutline}
               />
             {/if}
@@ -530,12 +568,14 @@
       {#key albumKey}
         {#if viewMode === ViewMode.SELECT_ASSETS}
           <AssetGrid
+            enableRouting={false}
             assetStore={timelineStore}
             assetInteractionStore={timelineInteractionStore}
             isSelectionMode={true}
           />
         {:else}
           <AssetGrid
+            enableRouting={true}
             {album}
             {assetStore}
             {assetInteractionStore}
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 6e3fb4cb28..2ce1309351 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
@@ -17,6 +17,7 @@
   import type { PageData } from './$types';
   import { mdiPlus, mdiDotsVertical } from '@mdi/js';
   import { t } from 'svelte-i18n';
+  import { onDestroy } from 'svelte';
 
   export let data: PageData;
 
@@ -25,6 +26,10 @@
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
   $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
+
+  onDestroy(() => {
+    assetStore.destroy();
+  });
 </script>
 
 {#if $isMultiSelectState}
@@ -45,7 +50,7 @@
 {/if}
 
 <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
-  <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
+  <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
     <EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" />
   </AssetGrid>
 </UserPageLayout>
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 49af165ac9..13e70c9161 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
@@ -19,6 +19,7 @@
   import type { PageData } from './$types';
   import { mdiDotsVertical, mdiPlus } from '@mdi/js';
   import { t } from 'svelte-i18n';
+  import { onDestroy } from 'svelte';
 
   export let data: PageData;
 
@@ -27,6 +28,10 @@
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
   $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
+
+  onDestroy(() => {
+    assetStore.destroy();
+  });
 </script>
 
 <!-- Multiselection mode app bar -->
@@ -50,7 +55,7 @@
 {/if}
 
 <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
-  <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
+  <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
     <EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" />
   </AssetGrid>
 </UserPageLayout>
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 3eb65ca1bd..0ea0ed18bb 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
@@ -124,7 +124,10 @@
           showNavigation={viewingAssets.length > 1}
           on:next={navigateNext}
           on:previous={navigatePrevious}
-          on:close={() => assetViewingStore.showAssetViewer(false)}
+          on:close={() => {
+            assetViewingStore.showAssetViewer(false);
+            handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
+          }}
           isShared={false}
         />
       {/await}
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 83e2ba3c1f..b580c4faa5 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
@@ -23,6 +23,7 @@
 
   onDestroy(() => {
     assetInteractionStore.clearMultiselect();
+    assetStore.destroy();
   });
 </script>
 
@@ -45,5 +46,5 @@
       </svelte:fragment>
     </ControlAppBar>
   {/if}
-  <AssetGrid {assetStore} {assetInteractionStore} />
+  <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} />
 </main>
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 02afe7f610..26e803deb6 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
@@ -52,7 +52,7 @@
     mdiEyeOutline,
     mdiPlus,
   } from '@mdi/js';
-  import { onMount } from 'svelte';
+  import { onDestroy, onMount } from 'svelte';
   import type { PageData } from './$types';
   import { listNavigation } from '$lib/actions/list-navigation';
   import { t } from 'svelte-i18n';
@@ -155,6 +155,7 @@
     }
     if (previousPersonId !== data.person.id) {
       handlePromiseError(updateAssetCount());
+      assetStore.destroy();
       assetStore = new AssetStore({
         isArchived: false,
         personId: data.person.id,
@@ -344,6 +345,10 @@
       await goto($page.url);
     }
   };
+
+  onDestroy(() => {
+    assetStore.destroy();
+  });
 </script>
 
 {#if viewMode === ViewMode.UNASSIGN_ASSETS}
@@ -442,6 +447,7 @@
 <main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
   {#key refreshAssetGrid}
     <AssetGrid
+      enableRouting={true}
       {assetStore}
       {assetInteractionStore}
       isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 85a497fa99..9bcbdbeea0 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -24,6 +24,7 @@
   import { mdiDotsVertical, mdiPlus } from '@mdi/js';
   import { preferences, user } from '$lib/stores/user.store';
   import { t } from 'svelte-i18n';
+  import { onDestroy } from 'svelte';
 
   let { isViewing: showAssetViewer } = assetViewingStore;
   const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@@ -48,6 +49,10 @@
       return;
     }
   };
+
+  onDestroy(() => {
+    assetStore.destroy();
+  });
 </script>
 
 {#if $isMultiSelectState}
@@ -84,6 +89,7 @@
 
 <UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
   <AssetGrid
+    enableRouting={true}
     {assetStore}
     {assetInteractionStore}
     removeAction={AssetAction.ARCHIVE}
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 7e5362ba0f..cd4def1765 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
@@ -291,7 +291,7 @@
         <GalleryViewer
           assets={searchResultAssets}
           bind:selectedAssets
-          on:intersected={loadNextPage}
+          onIntersected={loadNextPage}
           showArchiveIcon={true}
           {viewport}
         />
diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 5ebb0e294c..f4fac282ba 100644
--- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -11,8 +11,13 @@
   import type { PageData } from './$types';
   import { setSharedLink } from '$lib/utils';
   import { t } from 'svelte-i18n';
+  import { navigate } from '$lib/utils/navigation';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  import { tick } from 'svelte';
 
   export let data: PageData;
+
+  let { gridScrollTarget } = assetViewingStore;
   let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data;
   let { title, description } = meta;
   let isOwned = $user ? $user.id === sharedLink?.userId : false;
@@ -29,6 +34,11 @@
       description =
         sharedLink.description ||
         $t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
+      await tick();
+      await navigate(
+        { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
+        { forceNavigate: true, replaceState: true },
+      );
     } catch (error) {
       handleError(error, $t('errors.unable_to_get_shared_link'));
     }
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 2907a542b3..27ad5bb3f0 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
@@ -25,6 +25,7 @@
   import { handlePromiseError } from '$lib/utils';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
+  import { onDestroy } from 'svelte';
 
   export let data: PageData;
 
@@ -84,6 +85,10 @@
       handleError(error, $t('errors.unable_to_restore_trash'));
     }
   };
+
+  onDestroy(() => {
+    assetStore.destroy();
+  });
 </script>
 
 {#if $isMultiSelectState}
@@ -111,7 +116,7 @@
       </LinkButton>
     </div>
 
-    <AssetGrid {assetStore} {assetInteractionStore}>
+    <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}>
       <p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
         {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
       </p>
diff --git a/web/static/dark_skeleton.png b/web/static/dark_skeleton.png
new file mode 100644
index 0000000000..2a115a8496
Binary files /dev/null and b/web/static/dark_skeleton.png differ
diff --git a/web/static/light_skeleton.png b/web/static/light_skeleton.png
new file mode 100644
index 0000000000..22c7eae754
Binary files /dev/null and b/web/static/light_skeleton.png differ