diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 98f4cd5540..f420720e5b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -677,6 +677,9 @@ importers:
       '@formatjs/icu-messageformat-parser':
         specifier: ^2.9.8
         version: 2.11.4
+      '@immich/justified-layout-wasm':
+        specifier: ^0.4.3
+        version: 0.4.3
       '@immich/sdk':
         specifier: file:../open-api/typescript-sdk
         version: link:../open-api/typescript-sdk
@@ -2726,6 +2729,9 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@immich/justified-layout-wasm@0.4.3':
+    resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==}
+
   '@immich/ui@0.37.1':
     resolution: {integrity: sha512-8S9KsyqyRcNgRHeBU8G3qMQ7D7fN4u9I31jjRc9c3s2tkiYucASofPJdcFdmGZnKLX5fIj+yofxiNZV9tVitOg==}
     peerDependencies:
@@ -14182,6 +14188,8 @@ snapshots:
   '@img/sharp-win32-x64@0.34.4':
     optional: true
 
+  '@immich/justified-layout-wasm@0.4.3': {}
+
   '@immich/ui@0.37.1(@internationalized/date@3.8.2)(svelte@5.40.1)':
     dependencies:
       '@mdi/js': 7.4.47
diff --git a/web/eslint.config.js b/web/eslint.config.js
index 792ff90e0c..f8e6cdd9c6 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -122,6 +122,7 @@ export default typescriptEslint.config(
       'unicorn/prefer-top-level-await': 'off',
       'unicorn/import-style': 'off',
       'unicorn/no-array-sort': 'off',
+      'unicorn/no-for-loop': 'off',
       'svelte/button-has-type': 'error',
       '@typescript-eslint/await-thenable': 'error',
       '@typescript-eslint/no-floating-promises': 'error',
diff --git a/web/package.json b/web/package.json
index fad799813d..fb672af63a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -26,6 +26,7 @@
   },
   "dependencies": {
     "@formatjs/icu-messageformat-parser": "^2.9.8",
+    "@immich/justified-layout-wasm": "^0.4.3",
     "@immich/sdk": "file:../open-api/typescript-sdk",
     "@immich/ui": "^0.37.1",
     "@mapbox/mapbox-gl-rtl-text": "0.2.3",
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 e8c1a35dd7..11f6f5df57 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
@@ -16,7 +16,7 @@
   import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
   import { moveFocus } from '$lib/utils/focus-util';
   import { handleError } from '$lib/utils/handle-error';
-  import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
+  import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
   import { navigate } from '$lib/utils/navigation';
   import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
   import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
@@ -28,7 +28,7 @@
 
   interface Props {
     initialAssetId?: string;
-    assets: (TimelineAsset | AssetResponseDto)[];
+    assets: TimelineAsset[] | AssetResponseDto[];
     assetInteraction: AssetInteraction;
     disableAssetSelect?: boolean;
     showArchiveIcon?: boolean;
@@ -66,60 +66,26 @@
 
   let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
 
-  let geometry: CommonJustifiedLayout | undefined = $state();
-
-  $effect(() => {
-    const _assets = assets;
-    updateSlidingWindow();
-
-    const rowWidth = Math.floor(viewport.width);
-    const rowHeight = rowWidth < 850 ? 100 : 235;
-
-    geometry = getJustifiedLayoutFromAssets(_assets, {
+  const geometry = $derived(
+    getJustifiedLayoutFromAssets(assets, {
       spacing: 2,
-      heightTolerance: 0.15,
-      rowHeight,
-      rowWidth,
-    });
-  });
+      heightTolerance: 0.5,
+      rowHeight: Math.floor(viewport.width) < 850 ? 100 : 235,
+      rowWidth: Math.floor(viewport.width),
+    }),
+  );
 
-  let assetLayouts = $derived.by(() => {
-    const assetLayout = [];
-    let containerHeight = 0;
-    let containerWidth = 0;
-    if (geometry) {
-      containerHeight = geometry.containerHeight;
-      containerWidth = geometry.containerWidth;
-      for (const [index, asset] of assets.entries()) {
-        const top = geometry.getTop(index);
-        const left = geometry.getLeft(index);
-        const width = geometry.getWidth(index);
-        const height = geometry.getHeight(index);
+  const getStyle = (i: number) => {
+    const geo = geometry;
+    return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
+  };
 
-        const layoutTopWithOffset = top + pageHeaderOffset;
-        const layoutBottom = layoutTopWithOffset + height;
-
-        const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
-
-        const layout = {
-          asset,
-          top,
-          left,
-          width,
-          height,
-          display,
-        };
-
-        assetLayout.push(layout);
-      }
-    }
-
-    return {
-      assetLayout,
-      containerHeight,
-      containerWidth,
-    };
-  });
+  const isIntersecting = (i: number) => {
+    const geo = geometry;
+    const window = slidingWindow;
+    const top = geo.getTop(i);
+    return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
+  };
 
   let currentIndex = 0;
   if (initialAssetId && assets.length > 0) {
@@ -148,9 +114,9 @@
   let lastIntersectedHeight = 0;
   $effect(() => {
     // Intersect if there's only one viewport worth of assets left to scroll.
-    if (assetLayouts.containerHeight - slidingWindow.bottom <= viewport.height) {
+    if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
       // Notify we got to (near) the end of scroll.
-      const intersectedHeight = assetLayouts.containerHeight;
+      const intersectedHeight = geometry.containerHeight;
       if (lastIntersectedHeight !== intersectedHeight) {
         debouncedOnIntersected();
         lastIntersectedHeight = intersectedHeight;
@@ -264,7 +230,7 @@
     isShowDeleteConfirmation = false;
     await deleteAssets(
       !(isTrashEnabled && !force),
-      (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
+      (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]),
       assetInteraction.selectedAssets,
       onReload,
     );
@@ -277,7 +243,7 @@
       assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
     );
     if (ids) {
-      assets = assets.filter((asset) => !ids.includes(asset.id));
+      assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[];
       deselectAllAssets();
     }
   };
@@ -480,41 +446,36 @@
 {#if assets.length > 0}
   
-    {#each assetLayouts.assetLayout as layout, layoutIndex (layout.asset.id + '-' + layoutIndex)}
-      {@const currentAsset = layout.asset}
-
-      {#if layout.display}
-        
+    {#each assets as asset, i (asset.id + '-' + i)}
+      {#if isIntersecting(i)}
+        {@const currentAsset = toTimelineAsset(asset)}
+        
            {
               if (assetInteraction.selectionActive) {
-                handleSelectAssets(toTimelineAsset(currentAsset));
+                handleSelectAssets(currentAsset);
                 return;
               }
-              void viewAssetHandler(toTimelineAsset(currentAsset));
+              void viewAssetHandler(currentAsset);
             }}
-            onSelect={() => handleSelectAssets(toTimelineAsset(currentAsset))}
-            onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(currentAsset))}
+            onSelect={() => handleSelectAssets(currentAsset)}
+            onMouseEvent={() => assetMouseEventHandler(currentAsset)}
             {showArchiveIcon}
-            asset={toTimelineAsset(currentAsset)}
+            asset={currentAsset}
             selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
             selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
-            thumbnailWidth={layout.width}
-            thumbnailHeight={layout.height}
+            thumbnailWidth={geometry.getWidth(i)}
+            thumbnailHeight={geometry.getHeight(i)}
           />
-          {#if showAssetName && !isTimelineAsset(currentAsset)}
+          {#if showAssetName && !isTimelineAsset(asset)}
             
-              {currentAsset.originalFileName}
+              {asset.originalFileName}
             
           {/if}
          
diff --git a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts
index c5f48f735a..12526527b7 100644
--- a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts
+++ b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts
@@ -26,12 +26,12 @@ export abstract class VirtualScrollManager {
   #suspendTransitions = $state(false);
   #resetScrolling = debounce(() => (this.#scrolling = false), 1000);
   #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
-  #justifiedLayoutOptions = $derived.by(() => ({
+  #justifiedLayoutOptions = $derived({
     spacing: 2,
-    heightTolerance: 0.15,
+    heightTolerance: 0.5,
     rowHeight: this.#rowHeight,
     rowWidth: Math.floor(this.viewportWidth),
-  }));
+  });
 
   constructor() {
     this.setLayoutOptions();
diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts
index 57cf513a7d..a3d3194dd2 100644
--- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts
@@ -1,7 +1,7 @@
 import { AssetOrder } from '@immich/sdk';
 
 import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
-import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
+import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
 import { plainDateTimeCompare } from '$lib/utils/timeline-util';
 
 import { SvelteSet } from 'svelte/reactivity';
@@ -148,9 +148,9 @@ export class DayGroup {
     const geometry = getJustifiedLayoutFromAssets(assets, options);
     this.width = geometry.containerWidth;
     this.height = assets.length === 0 ? 0 : geometry.containerHeight;
+    // TODO: lazily get positions instead of loading them all here
     for (let i = 0; i < this.viewerAssets.length; i++) {
-      const position = getPosition(geometry, i);
-      this.viewerAssets[i].position = position;
+      this.viewerAssets[i].position = geometry.getPosition(i);
     }
   }
 
diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
index c9ac596e34..a3fda3a85c 100644
--- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
+++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
@@ -82,15 +82,15 @@ describe('TimelineManager', () => {
 
       expect(plainMonths).toEqual(
         expect.arrayContaining([
-          expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
-          expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
+          expect.objectContaining({ year: 2024, month: 3, height: 283 }),
+          expect.objectContaining({ year: 2024, month: 2, height: 7711 }),
           expect.objectContaining({ year: 2024, month: 1, height: 286 }),
         ]),
       );
     });
 
     it('calculates timeline height', () => {
-      expect(timelineManager.totalViewerHeight).toBe(12_507.5);
+      expect(timelineManager.totalViewerHeight).toBe(8340);
     });
   });
 
diff --git a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts
index b6e28df576..161cc049f1 100644
--- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts
@@ -18,7 +18,7 @@ export class ViewerAsset {
     return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
   });
 
-  position: CommonPosition | undefined = $state();
+  position: CommonPosition | undefined = $state.raw();
   asset: TimelineAsset = 
$state();
   id: string = $derived(this.asset.id);
 
diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts
index e850371b16..16adb79f67 100644
--- a/web/src/lib/utils/layout-utils.ts
+++ b/web/src/lib/utils/layout-utils.ts
@@ -1,16 +1,15 @@
-// import { TUNABLES } from '$lib/utils/tunables';
-// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
-// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
+import { TUNABLES } from '$lib/utils/tunables';
+import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
 
 import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
 import { getAssetRatio } from '$lib/utils/asset-utils';
-import { isTimelineAsset } from '$lib/utils/timeline-util';
+import { isTimelineAsset, isTimelineAssets } from '$lib/utils/timeline-util';
 import type { AssetResponseDto } from '@immich/sdk';
 import createJustifiedLayout from 'justified-layout';
 
 export type getJustifiedLayoutFromAssetsFunction = typeof getJustifiedLayoutFromAssets;
 
-// let useWasm = TUNABLES.LAYOUT.WASM;
+const useWasm = TUNABLES.LAYOUT.WASM;
 
 export type CommonJustifiedLayout = {
   containerWidth: number;
@@ -19,6 +18,7 @@ export type CommonJustifiedLayout = {
   getLeft(boxIdx: number): number;
   getWidth(boxIdx: number): number;
   getHeight(boxIdx: number): number;
+  getPosition(boxIdx: number): { top: number; left: number; width: number; height: number };
 };
 
 export type CommonLayoutOptions = {
@@ -29,25 +29,31 @@ export type CommonLayoutOptions = {
 };
 
 export function getJustifiedLayoutFromAssets(
-  assets: (TimelineAsset | AssetResponseDto)[],
+  assets: TimelineAsset[] | AssetResponseDto[],
   options: CommonLayoutOptions,
 ): CommonJustifiedLayout {
-  // if (useWasm) {
-  //   return wasmJustifiedLayout(assets, options);
-  // }
+  if (useWasm) {
+    return isTimelineAssets(assets) ? wasmLayoutFromTimeline(assets, options) : wasmLayoutFromDto(assets, options);
+  }
   return justifiedLayout(assets, options);
 }
 
-// commented out until a solution for top level awaits on safari is fixed
-// function wasmJustifiedLayout(assets: AssetResponseDto[], options: LayoutOptions) {
-//   const aspectRatios = new Float32Array(assets.length);
-//   // eslint-disable-next-line unicorn/no-for-loop
-//   for (let i = 0; i < assets.length; i++) {
-//     const { width, height } = getAssetRatio(assets[i]);
-//     aspectRatios[i] = width / height;
-//   }
-//   return new JustifiedLayout(aspectRatios, options);
-// }
+function wasmLayoutFromTimeline(assets: TimelineAsset[], options: LayoutOptions) {
+  const aspectRatios = new Float32Array(assets.length);
+  for (let i = 0; i < assets.length; i++) {
+    aspectRatios[i] = assets[i].ratio;
+  }
+  return new JustifiedLayout(aspectRatios, options);
+}
+
+function wasmLayoutFromDto(assets: AssetResponseDto[], options: LayoutOptions) {
+  const aspectRatios = new Float32Array(assets.length);
+  for (let i = 0; i < assets.length; i++) {
+    const { width, height } = getAssetRatio(assets[i]);
+    aspectRatios[i] = width / height;
+  }
+  return new JustifiedLayout(aspectRatios, options);
+}
 
 type Geometry = ReturnType;
 class Adapter {
@@ -88,6 +94,11 @@ class Adapter {
   getHeight(boxIdx: number) {
     return this.result.boxes[boxIdx]?.height;
   }
+
+  getPosition(boxIdx: number) {
+    const box = this.result.boxes[boxIdx];
+    return { top: box.top, left: box.left, width: box.width, height: box.height };
+  }
 }
 
 export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) {
@@ -119,12 +130,3 @@ export type CommonPosition = {
   width: number;
   height: number;
 };
-
-export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition {
-  const top = geometry.getTop(boxIdx);
-  const left = geometry.getLeft(boxIdx);
-  const width = geometry.getWidth(boxIdx);
-  const height = geometry.getHeight(boxIdx);
-
-  return { top, left, width, height };
-}
diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts
index 60811c24f0..3326676f3c 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -195,6 +195,9 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
 export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
   (unknownAsset as TimelineAsset).ratio !== undefined;
 
+export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] =>
+  assets.length === 0 || 'ratio' in assets[0];
+
 export const plainDateTimeCompare = (ascending: boolean, a: TimelineDateTime, b: TimelineDateTime) => {
   const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a];
 
diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts
index 6ce64ed041..c586e11957 100644
--- a/web/src/lib/utils/tunables.ts
+++ b/web/src/lib/utils/tunables.ts
@@ -19,7 +19,7 @@ const storage = browser
     };
 export const TUNABLES = {
   LAYOUT: {
-    WASM: getBoolean(storage.getItem('LAYOUT.WASM'), false),
+    WASM: getBoolean(storage.getItem('LAYOUT.WASM'), true),
   },
   TIMELINE: {
     INTERSECTION_EXPAND_TOP: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts
index 8d328ddcc7..88316ccafb 100644
--- a/web/src/test-data/factories/asset-factory.ts
+++ b/web/src/test-data/factories/asset-factory.ts
@@ -32,7 +32,7 @@ export const assetFactory = Sync.makeFactory({
 
 export const timelineAssetFactory = Sync.makeFactory({
   id: Sync.each(() => faker.string.uuid()),
-  ratio: Sync.each(() => faker.number.int()),
+  ratio: Sync.each((i) => 0.2 + ((i * 0.618_034) % 3.8)), // deterministic random float between 0.2 and 4.0
   ownerId: Sync.each(() => faker.string.uuid()),
   thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
   localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),