cf.completefrontend
My progress
SYSTEM DESIGN · #133

Design Google Photos

The web app for a personal library of 50,000 to 200,000 photos and videos: one timeline grid that keeps every photo's shape, a date rail that jumps ten years in one drag, selection across rows that were never loaded, and thousands of uploads landing at their capture date. The hard parts are the size math, a justified layout computed lazily per day, virtualizing sections whose heights are guesses, and keeping the scroll position still when the guesses get corrected.

The question

Design the web app for Google Photos. A signed-in person sees their whole library as one long timeline: photos and videos grouped by day, newest first, in rows that fill the width. A rail on the right lets them jump to any month in years of history. They can select photos, shift-click a range, and act on the selection: add to an album, share, download, delete. They can drop thousands of files from their computer and watch them appear in the timeline.

Heavy users have 200,000 items spanning 20 years. The page must open fast on a mid-range laptop and stay smooth while they fling through it. Walk me through the grid, the scrolling, the rail, selection, and uploads, and how you would keep memory and network under control.

Real-life examples: Google Photos on the web, iCloud Photos at icloud.com, Amazon Photos, Microsoft OneDrive Photos view

Requirements and the size math

Google Photos looks like Instagram or Pinterest with your own pictures, but it is a different problem. A feed is endless but shallow: people read the top and rarely go back. A personal library is finite but deep, and people go back on purpose: "show me the beach trip in August 2014".

That shapes the whole design. You must be able to reach any point in 20 years of photos in about a second, without loading the 19 years in between.

Spend the first five minutes on questions whose answers move the design. The table shows the questions this guide asks, the answer it assumes, and what each answer changes.

QuestionAnswer we assumeWhat it changes
How big is a large library?50,000 typical heavy user, 200,000 for the top 1 percent, spanning up to 20 yearsMetadata cannot be fetched at once; we need a summary first and pages by date
What is the main screen?One timeline, newest first, grouped by day, with month and year headingsLayout and virtualization are per day section, not per photo
Do photos keep their shape?Yes: no square crops in the main grid, rows of equal height that fill the widthA justified layout, computed in JavaScript, not CSS
How do people jump far back?A date rail (the scrubber) on the right, drag to any monthEvery section needs a height before its photos are loaded
What can people do with many photos?Select by click, shift-click, a whole day, or keyboard; then delete, share, add to album, downloadSelection must cover photos that were never loaded
Uploads from the web?Yes: drop a folder of up to 5,000 filesA queue, de-duplication, and placeholders at the capture date
Search?By place, person, thing, and date; the server does the understandingSearch results are a second grid that reuses the layout
Devices?Laptops and desktops first, 1280 to 2560 pixels wide, device pixel ratio 1 to 2; phones use the native appThumbnail sizes and how many fit on a screen

Functional requirements

  • Timeline: every photo and video, newest first, grouped by day with date headings, in justified rows that keep each item's aspect ratio.
  • Date scrubber: a rail with year and month marks; dragging shows the date under the pointer and jumps there on release.
  • Selection: click a checkmark, shift-click a range, select a whole day, keyboard selection; a bar shows the count and the bulk actions.
  • Uploads: drag and drop or pick files; new items appear at their capture date with a processing state.
  • Videos and live photos in the grid, with a duration badge and a short preview on hover.
  • Search by text, and pages for albums, people, and places. Opening a photo goes to the viewer.
Wireframe of the timeline. A top bar holds a search box, an Upload button, and a selection count reading 9,214 selected. Below, a day heading for Saturday 14 March 2015 sits over two justified rows: the first row has five photos of different widths, one of them a video with a 0:42 badge and one selected; the second row has a wide panorama, selected, and three more photos. A heading for Friday 13 March 2015 sits over a third row of hatched placeholder tiles that have not loaded yet. On the right edge, a date rail shows the years 2026, 2020, 2015, and 2009, with 2015 highlighted.Photos: timelineSearch your photosUpload9,214 selectedSat, 14 Mar 2015Fri, 13 Mar 20150:422026202020152009
Rows fill the width and keep each photo's shape, so tile widths vary. The rail on the right replaces the scrollbar. Hatched tiles are placeholders: their positions are known before their pixels arrive.

Non-functional requirements, with numbers

  • Open fast: the first screen of thumbnails in under 1.5 seconds on a warm visit and under 2.5 seconds cold (Largest Contentful Paint under 2.5 seconds is the Core Web Vitals good threshold).
  • Smooth scrolling at 60 frames per second: each frame has about 16 ms and the browser needs part of that, so our own layout and DOM work per frame must stay under about 10 ms.
  • Memory flat with library size: a tab with 200,000 items must not hold 200,000 DOM nodes or 200,000 decoded images. Target under 300 MB for the tab (an estimate for a mid-range laptop).
  • A scrubber jump to any year shows thumbnails within about 1 second on a good connection.
  • Accessible: the grid works with a keyboard and a screen reader; selection counts are announced.

Out of scope, and where it lives

Several neighbours of this problem have their own guides, and this one links them instead of repeating them:

  • The full-screen viewer with zoom and swipe: the image lightbox guide. Swiping through a handful of photos in a post or a memory: the image carousel guide.
  • Editing a photo, with filters, crops, and undo: the photo editor guide.
  • Moving bytes in resumable chunks: the file uploader guide.
  • Sharing, permissions, and link access: the Google Drive guide.
  • A feed of other people's photos: the Instagram guide. A masonry grid of pins: the Pinterest guide.
  • Windowing a one-dimensional list: the virtualized list guide. Queued offline edits that sync later: the offline sync engine guide.

Here we focus on what is special about a huge personal library: size, the justified grid, sections, the scrubber, selection at scale, and uploads that land in the past.

Check your understanding · Exercise 1

Which answers to scope questions change this design the most? Select all that apply.

What makes this hard: the size math

Before choosing any technique, do the arithmetic for the biggest library, 200,000 items. Every number below is an estimate, rounded so you can redo it on a whiteboard. The point is not the exact value but which things fit in memory and which never can.

Metadata: about 50 MB if you are careless

To lay out one photo the grid needs its id, capture time, width and height, whether it is a video (and its duration), a placeholder colour, and a base URL for the image service. As JSON with readable keys that is about 250 bytes per item (estimate).

For 200,000 items that is 50 MB of JSON, roughly 10 to 15 MB after gzip, and more again once parsed into JavaScript objects. Fetching it all on open would take seconds on a good connection and freeze the main thread while parsing.

What the grid needs on open is much smaller: how many items each stretch of time holds. Group the library by month, split the busiest months, and 20 years becomes about 300 sections. Each needs only an id, a date range, an item count, and the number of days with photos: about 100 bytes of JSON, so 30 KB raw and under 10 KB gzipped (estimate).

That one small response is enough to give every section an estimated height, draw the scrubber, and know the total scroll height. The full metadata of a section is fetched only when it comes near the screen.

Thumbnails: about 1 MB per screen

Take a 1440 by 900 window and a target row height of 200 CSS pixels. Typical photos are 4:3 or 3:2, so a row holds about 5 items and the screen shows about 4.5 rows: roughly 25 thumbnails.

On a display with device pixel ratio 2, each thumbnail should be about 400 pixels tall and 560 wide. As WebP or AVIF at that size, 25 to 50 KB each (estimate). One screen is about 1 MB, and with one screen of overscan above and below, about 3 MB for 75 tiles.

Decoded, the cost is different. A 560 by 400 image is 224,000 pixels at 4 bytes each, so about 0.9 MB of memory per thumbnail, and 75 decoded tiles cost about 67 MB.

If the page kept every thumbnail it ever showed, scrolling through 2,000 photos would hold 1.8 GB of decoded pixels. This is why tiles must leave the DOM when they scroll away: dropping the element lets the browser drop the decoded image too.

DOM and scroll height

One tile is about 4 elements: a wrapper, the image, a checkmark, and a badge for videos. 200,000 tiles would be 800,000 elements, far beyond the roughly 1,400 elements that Lighthouse's DOM size audit has long treated as excessive. With virtualization we keep about 75 tiles plus a few section headers: under 500 elements whatever the library size.

The scroll height is the sum of all rows. 200,000 items at 5 per row is 40,000 rows of 204 pixels including the gap. Add a 48 pixel heading for each day with photos (about 3,000 of the 7,300 days in 20 years, estimate) and the total is about 8.3 million pixels.

Browsers cap the height of one element somewhere in the tens of millions of pixels, and the cap differs by browser and version (figures from about 17 to 33 million are commonly reported). Our largest library fits, but a design for millions of items would need a scaled scroll space. Note it and move on.

ThingFor 200,000 itemsWhat we do
Section summary (about 300 sections)About 30 KB raw, under 10 KB gzipped (estimate)Fetch on open, cache in IndexedDB
Full metadataAbout 50 MB JSON (estimate)Fetch per date range, only near the viewport
Thumbnail bytesAbout 1 MB per screen at DPR 2 (estimate)Size to the row height, cancel when scrolled away
Decoded pixelsAbout 0.9 MB per tile (estimate)Only visible tiles plus overscan exist
DOM elements800,000 without windowingUnder 500 with section virtualization
Scroll heightAbout 8.3 million pixelsOne spacer element; sections absolutely positioned
Size budget for a library of 200,000 items, all estimates. The section summary is under 10 KB gzipped and fits. All metadata is about 50 MB of JSON and does not. One screen of thumbnails is about 1 MB and fits. 75 decoded tiles take about 67 MB and fit. Every tile decoded would be about 180 GB and never fits.section summaryunder 10 KBall metadataabout 50 MBone screen of thumbs1 MB75 tiles, decoded67 MBevery tile, decoded180 GB
Green fits in a tab, red never will. The bars are not to scale: the gap between the summary and all metadata is more than a thousand times. Everything the design does keeps the tab on the green rows.

Put together, four characteristics drive every later chapter. The library is deep and accessed at random, so positions must exist before data does. Items have different shapes, so heights are unknown until a layout runs.

The media is heavy, so loading order and cancellation matter more than in a feed. And actions apply to thousands of items at once, so selection and bulk actions cannot be lists of loaded objects. The estimation lesson goes deeper into this style of back-of-the-envelope reasoning.

Check your understanding · Exercise 2

A teammate proposes loading all 200,000 metadata records on open so the grid can compute an exact layout. What goes wrong? Select all that apply.

The justified grid: rows that keep every photo's shape

The grid is the product. People spot a cropped head or a squashed panorama at once, so the layout rule is strict: every photo keeps its aspect ratio, every row fills the full width, the gaps are all equal, and all photos in one row share one height. Rows may differ a little in height from each other. That is a justified layout, like justified text where each line ends flush with the margin.

The maths for one row is short: each photo has an aspect ratio a (width divided by height), and placed at height h it is a times h wide. A row of n photos with gap g fills width W when h equals (W minus g times (n minus 1)) divided by the sum of the aspect ratios. So choosing a row is choosing where to break: more photos give a shorter row, fewer give a taller one.

Worked example with W = 1000, g = 4, and a target height of 200. Photos with ratios 1.5, 0.75, 1.33 and 1.5 sum to 5.08, so h = 988 / 5.08 = 194.5 pixels: close to the target. Add the next photo, a 3.0 panorama, and the sum becomes 8.08, so h = 984 / 8.08 = 121.8 pixels: far too short. Drop to three photos and h = 992 / 3.58 = 277 pixels: too tall.

Decision How do we lay out a day's photos?

Square crops in a CSS grid

grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) with object-fit: cover on each image.

Every tile is the same size, so the position of item i is simple arithmetic.

Pros

  • Trivial layout; heights known from the count alone, so virtualization and the scrubber are easy.
  • No metadata needed beyond the count.

Cons

  • Crops every photo: portraits lose heads, panoramas lose most of their width.
  • Fails the core requirement of this product.

Flex-wrap with grow equal to the aspect ratio

Each tile gets flex: 1.5 1 300px style values: grow and basis proportional to its ratio, so items in a row grow together and keep equal heights.

The browser decides where rows break.

Pros

  • No layout code; resizes are handled by the browser.
  • Rows fill the width and keep shapes.

Cons

  • The browser breaks greedily and you cannot tune it; the last row stretches unless you add a filler.
  • Heights are known only after the browser lays out, so you must render to measure: bad for virtualizing 200,000 items.
  • Positions are not in JavaScript, so keyboard navigation and range selection need DOM reads.

Greedy rows in JavaScript

Walk the photos, add each at the target height, and close the row as soon as the total width passes W; scale that row down to fit.

Flickr's open source justified-layout library refines this: it closes a row once its height is within a tolerance of the target, and when one more photo overshoots, it keeps whichever row, with or without that photo, lands closer to the target.

Pros

  • Linear time, tiny code, exact positions in JavaScript.
  • Good enough for most rows.

Cons

  • Decides one row at a time, so one bad break (a panorama arriving at the end of a row) can make a very short row that a better earlier break would have avoided.
  • The plain version always scales rows down, so rows land under the target height.

Optimal breaks (line breaking as a shortest path)

Treat each possible break as a node and each acceptable row as an edge with a cost such as the squared distance from the target height.

Find the cheapest path from the first photo to the last with dynamic programming, like the Knuth and Plass algorithm for text.

Pros

  • Rows stay close to the target height overall; panoramas and portraits stop producing outlier rows.
  • Still fast: rows have at most about 10 photos, so the work is about n times 10 per day.

Cons

  • More code to write and test.
  • A change at the start of a day can move breaks at its end, so the layout must be recomputed per day, not patched.

What we pick: Optimal breaks in JavaScript, run per day. We need positions in JavaScript for virtualization, the scrubber, and keyboard navigation, which rules out letting CSS decide. Between the two JavaScript options, the optimal one costs a few dozen lines and removes the ugly outlier rows. The 2018 Google Photos post reports its version taking about 2 ms for 100 photos and 10 ms for 1,000. Greedy stays useful as a fallback and for estimates.

Eight photos with aspect ratios 1.5, 0.75, 1.33, 1.5, 3 (a panorama), 0.67, 1.5, and 1, in a 600 pixel row width with a target height of 100. Greedy breaking fills the first row until it overflows, taking the panorama too, so row 1 is 71 pixels tall and the last row is 100: cost 824. Optimal breaking puts four photos in row 1 at 115 pixels and the panorama with three others in row 2 at 94 pixels: cost 244.greedy: break when fulloptimal: cheapest totalrow 1: 71 pxlast row: 100 pxcost: 824row 1: 115 pxlast row: 94 pxcost: 244same 8 photos, width 600, target height 100
Greedy decides one row at a time, so the panorama (hatched) lands at the end of a full row and squashes it. Choosing breaks for the whole day keeps both rows near the target: a little over and a little under instead of far under.

The optimal row breaker

best[i] is the cheapest cost of laying out the first i photos. For each end i we try every start j within the last 10 photos, compute the row height, skip rows outside the allowed range (here 0.7 to 1.5 times the target), and keep the cheapest. A lone photo is always allowed as a row, so a layout always exists; walking the remembered breaks back from the end gives the rows.

ts
type Row = { start: number; end: number; height: number };

export function layoutDay(ratios: number[], width: number, target: number, gap = 4): Row[] {
  const n = ratios.length;
  const best = new Array(n + 1).fill(Infinity);
  const from = new Array(n + 1).fill(0);
  best[0] = 0;
  for (let end = 1; end <= n; end++) {
    let sum = 0;
    for (let start = end - 1; start >= Math.max(0, end - 10); start--) {
      sum += ratios[start];
      const h = (width - gap * (end - start - 1)) / sum;
      const isLast = end === n;
      const single = end - start === 1; // a lone photo is always a valid row, so a layout always exists
      if (h < target * 0.7 && !single) break; // more photos only make it shorter
      if (h > target * 1.5 && !isLast && !single) continue;
      const cost = isLast && h > target ? 0 : (h - target) ** 2;
      if (best[start] + cost < best[end]) { best[end] = best[start] + cost; from[end] = start; }
    }
  }
  const rows: Row[] = [];
  for (let end = n; end > 0; end = from[end]) {
    const start = from[end];
    const sum = ratios.slice(start, end).reduce((a, b) => a + b, 0);
    const fit = (width - gap * (end - start - 1)) / sum;
    rows.unshift({ start, end, height: end === n ? Math.min(fit, target) : fit });
  }
  return rows;
}
The last row is free to be short of the width: it keeps the target height and is left aligned instead of being stretched.

Edge cases worth naming. A single panorama with ratio 6 at W = 1000 gives a row 166 pixels tall on its own; with the 0.7 limit that is allowed, so it gets its own row instead of crushing four neighbours. A day with one portrait photo is a last row: it keeps the target height and sits on the left.

An extreme panorama that is too short even alone still gets its own row, as the code allows, rather than failing the layout. Very short days waste space with a heading each, so the Google Photos web grid runs the same algorithm a second time to pack small days side by side within a section.

Why not the Pinterest masonry layout? Masonry fills columns, so the reading order zigzags and two photos taken a minute apart can land far apart vertically. In a timeline, order is meaning: rows read left to right in time order, which masonry cannot promise. The Pinterest guide covers masonry; here it is the wrong tool.

Layout
Fourteen photos laid out with optimal rows in a 1000 pixel container with a target row height of 180. 4 rows: 198, 159, 191, 180 px against a target of 180. The worst full row is 21 px off target. Hatched tiles are panoramas.1234567891011121314rows: 198, 159, 191, 180worst full row: 21 px offwidth 1000, target 180drawn at 40% size
4 rows: 198, 159, 191, 180 px against a target of 180. The worst full row is 21 px off target. Hatched tiles are panoramas.

Check your understanding · Exercise 3

Why does this design compute the justified layout in JavaScript instead of using flex-wrap with flex-grow set to each aspect ratio? Select all that apply.

Virtualizing sections: estimated heights that get corrected

The virtualized list guide windows a one-dimensional list of known or measured rows. Our grid adds two twists. It is nested (sections hold days, days hold rows, rows hold tiles), and most sections have never been laid out, because their photos have not been fetched. We still need a height for each, or the scrollbar and the scrubber are meaningless.

Sections, days, rows, tiles

The summary from the size math splits the library into sections: buckets of up to a few thousand items, usually a month, split further for heavy months. Inside a section, the client groups photos into days (the Google Photos post calls them segments), lays each day out into rows, and rows hold tiles.

Each level is absolutely positioned relative to its parent: a section's top is relative to the grid, a row's top relative to its section. Moving a section therefore changes one value, not thousands. CSS contain: layout paint on each section tells the browser that nothing inside affects the outside, which keeps layout and paint work local.

Sections in the model and in the DOM. On the left, the scroll space as a column of monthly sections: October and September 2026 are measured, August 2026 is in view, July 2026 is measured, and June 2026 down to November 2006 are estimated from their counts. A bracket marks the viewport across the end of September, August, and the start of July. On the right, the DOM holds only three sections: September 2026 with 4 rows mounted, August 2026 with all 6 rows, and July 2026 with 2 overscan rows. One spacer element gives the page its full 8.3 million pixel height.scroll space (model)Oct 2026Sep 2026Aug 2026Jul 2026Jun 2026: guessNov 2006: guess... 234 more ...in viewDOM: about 400 elementssection Sep 20264 rows mountedsection Aug 2026all 6 rowssection Jul 20262 rows, overscanspacer: 8.3 million px tall
Every section has a top, measured or guessed, so the scrollbar and the rail are always meaningful. Only the sections that overlap the viewport plus overscan exist as elements, and inside them only the visible rows.

Estimating a section from its count

With only a count, guess the layout. Assume an average aspect ratio (about 1.4 is a fair start for a mix of landscape and portrait photos), so a row at target height 200 in a 1000 pixel wide grid holds 1000 / (1.4 x 200), about 3.6 photos. A month of 600 photos on 20 days is then 168 full rows, plus about 10 more because each day ends with a part row: 178 rows of 204 pixels plus 20 day headings of 48 pixels, about 37,300 pixels.

The real layout may differ by a few thousand pixels. That is fine: the estimate only has to be roughly right so the scrollbar and rail feel proportional. Once the user has seen a section, store its real average ratio, and later estimates get better.

ts
const HEADER = 48;
const GAP = 4;

export function estimateHeight(s: { count: number; days: number }, width: number, target: number, avgRatio = 1.4) {
  const perRow = Math.max(1, width / (avgRatio * target));
  const rows = Math.ceil(s.count / perRow) + s.days * 0.5; // each day ends with a part row
  return Math.round(rows * (target + GAP) + s.days * HEADER);
}

// tops[i] = sum of heights of sections before i: one pass over about 300 numbers
export function prefixTops(heights: number[]): number[] {
  const tops = new Array(heights.length + 1);
  tops[0] = 0;
  for (let i = 0; i < heights.length; i++) tops[i + 1] = tops[i] + heights[i];
  return tops;
}
Recomputing all tops takes microseconds for a few hundred sections, so there is no need for a fancier structure.

Which sections exist in the DOM

On each scroll, batched to one requestAnimationFrame callback per frame, we binary search tops for the sections that overlap the viewport plus one screen of overscan, then binary search their rows the same way. Sections in the window are mounted; their tiles exist only for visible rows. Everything else is a single tall spacer that gives the page its scroll height. The DOM stays at a few hundred elements whether the library has 5,000 items or 200,000.

Correcting an estimate without a jump

The hard moment: the user dragged the rail to March 2015, and metadata for April 2015 arrives. In a newest-first timeline April sits just above March, and its real height is 2,400 pixels more than the guess, so every section below it must move down 2,400 pixels.

If the changed section is below the viewport, nothing visible moves and we are done. If it is above, as April is here, the content in view moves down 2,400 pixels and the user sees a jump, unless we also add 2,400 to the scroll position in the same frame.

Decision How do we keep what the user sees still when a section above changes height?

Browser scroll anchoring

Rely on CSS scroll anchoring (on by default, controlled with overflow-anchor): the browser picks an anchor node in view and adjusts the scroll offset when content above it changes size.

Pros

  • No code in Chrome and Firefox, which have shipped it for years.

Cons

  • Safari only added it in version 27 (2026), so older Safari versions still in use get no anchoring.
  • Anchoring is suppressed when the anchor or an ancestor changes top, transform, or height, which is exactly how we move sections.
  • The browser picks the anchor, not us, so we cannot say which tile should stay still.

Manual anchor in our layout model

Before applying a correction, record the anchor: the first visible tile's id and its offset from the viewport top.

Apply the new heights, recompute tops, then set scrollTop so the anchor tile is at the same offset, all inside one animation frame.

Turn browser anchoring off on the grid with overflow-anchor: none so the two do not both correct.

Pros

  • Exact and predictable, since we already know every position.
  • Works the same in every browser.

Cons

  • Must run in the same frame as the DOM change, or the user sees one frame of jump.
  • Fights with momentum scrolling on some trackpads if done mid-fling; batching corrections to scroll idle helps.

Never correct: squeeze into the estimate

Choose row heights so the real layout exactly fills the estimated height.

Pros

  • Nothing below ever moves.

Cons

  • Rows get stretched or squashed far from the target, and the estimate can be off by thousands of pixels.
  • The Google Photos post describes setting out to fit layouts to the estimate, then dropping the idea once simple corrections proved smooth.

What we pick: Manual anchoring. We own every position, so we can keep the anchor tile still exactly, and we disable browser anchoring so it does not double-correct. The guess only has to be good enough for the scrollbar.

Correcting an estimate without a jump, in a newest-first timeline. Before: April 2015 has an estimated height of 30,000 pixels, March 2015 is below it, and the viewport shows part of March with scrollTop 61,000. After: April's real height is 32,400 pixels, so March moves down by 2,400; scrollTop also grows by 2,400 to 63,400, and the viewport still shows the same part of March.before: Apr is a guessafter: real heightApr est 30,000Mar 2015viewApr real 32,400Mar 2015viewscrollTop 61,000scrollTop 63,400 (+2,400)
The section above grew, so everything below it moved down. Adding the same 2,400 pixels to scrollTop in the same frame keeps the anchor tile exactly where the user saw it.
ts
function applyCorrection(sectionIndex: number, realHeight: number) {
  const anchor = grid.firstVisibleTile(); // { id, top } in grid coordinates
  const offset = anchor.top - scroller.scrollTop;

  heights[sectionIndex] = realHeight;
  tops = prefixTops(heights);
  grid.positionMountedSections(tops); // writes only: style.transform per section

  const newTop = grid.tileTop(anchor.id, tops); // from the model, no DOM reads
  scroller.scrollTop = newTop - offset;
}
No DOM reads between writes, so the browser lays out once. The tile positions come from our model, not from getBoundingClientRect.

Check your understanding · Exercise 4

The user is looking at June 2016 in the newest-first timeline. Metadata for December 2016 (above the view) and January 2016 (below it) both arrive, and both sections are 1,000 pixels taller than estimated. What should happen to scrollTop? Pick one.

The date scrubber: jumping ten years in one drag

The native scrollbar cannot do this job. Our scroll height is about 8.3 million pixels and the scrollbar track is about 850 pixels tall, so one pixel of thumb movement is about 9,800 pixels of content: roughly 48 rows, or a week of photos. Worse, it has no labels: you cannot see where 2014 is.

The scrubber is a custom rail on the right edge with year labels, a date tooltip under the pointer, and a drag handle. It replaces the scrollbar visually but drives the same scroll position.

Decision What does a position on the rail mean?

Proportional to time

The top is today, the bottom is the oldest photo; one pixel is the same number of days everywhere.

Pros

  • Labels evenly spaced, easy to read.

Cons

  • Rail and scroll position disagree: the rail no longer works as a scrollbar.
  • A year with 50 photos gets as much rail as a year with 20,000, so dragging through busy years is coarse and sparse years are empty.

Proportional to content height

Rail position y maps to scrollTop = y / railHeight x totalHeight; the date at y is the date of the section at that scroll position.

Pros

  • The rail is a true scrollbar: the handle tracks the scroll exactly.
  • Busy years get more rail, which matches where people look.

Cons

  • Labels unevenly spaced; sparse years can collide and some labels must be hidden.
  • Positions move when estimates are corrected, so labels must be recomputed.

What we pick: Proportional to content height. The rail has to stay in sync with scrolling, and giving busy years more space is a feature. We hide year labels that would sit within 20 pixels of the previous one, and recompute label positions when heights change (cheap: one pass over the tops).

What happens during a drag

  1. pointerdown on the rail captures the pointer (setPointerCapture) so the drag continues if the pointer leaves the rail.
  2. Each pointermove updates the handle and the tooltip immediately: binary search tops for the section at that scroll position and show its month and year. No network yet.
  3. Scrolling follows the pointer, batched to one update per animation frame. Sections passed on the way stay as placeholders: the grid paints a CSS background texture of grey rows at the target row height (a trick the Google Photos post describes), so fast movement still looks like photos rushing by.
  4. When the pointer pauses for about 150 ms, or on pointerup, fetch metadata for the section under the viewport and its neighbours. A new pause aborts the previous fetch with AbortController, so a drag across 10 years never fires 120 section requests.
  5. When metadata arrives, lay out the section, apply height corrections with the anchor rule from the previous chapter, then request thumbnails for visible rows.
Sequence of a scrubber drag between the user, the scrubber, the grid engine, and the API. The user drags toward 2015. The scrubber asks the engine which section is at that position, using the summary, and shows a Mar 2015 tooltip. Scrolling follows once per frame with placeholder rows. The user lets go. The engine sends GET for section 2015-03 to the API. On success the items come back; on failure a 503 comes back and the engine backs off and shows a retry row. Finally the engine lays out the section, applies the anchor, and loads thumbnails for the user.UserScrubberGrid engineAPIdrag to 2015date at y?Mar 2015scroll per framelet goGET 2015-03itemsor 503: retry rowrows, then thumbs
Nothing touches the network while the pointer moves: the tooltip and the scroll come from the summary. One request goes out where the drag stops, and a failure leaves placeholder rows with a retry instead of a blank screen.

After a jump to March 2015, the sections between today and 2015 still have estimated heights. Each one laid out later is above the viewport, so the anchor rule adds its correction to scrollTop. The user stays on March 2015; only the handle moves slightly, because the total height changed and the handle shows the true proportion.

Think first

Should the URL change when the user scrubs to March 2015?

Keyboard and screen reader users need the same jump. Give the rail role="slider" with aria-valuetext set to the month and year ("March 2015"), arrow keys to move one month, Page Up and Page Down to move one year, and Home and End for newest and oldest. A "Go to date" option in the menu is a second path for people who prefer typing a date.

The scrubber lab below simulates 150,000 photos in 240 monthly sections. Drag the rail, let go, and watch estimates get replaced by real heights without moving the view; then turn anchoring off to see the jump it prevents.

A date rail over 150,000 simulated photos in 240 monthly sections. The view is at Oct 2026, scrollTop 0 of 9,293,815 pixels. 2 sections are loaded with real heights; the rest use estimates. Scroll anchoring is on. Months 1 and 2 are loaded. Drag the rail far down, then let go to load the sections there.20262024202220202018201620122006Oct 2026Oct 2026: 212,236Sep 2026: 215,127Aug 2026: ~226,992Jul 2026: ~363,060Jun 2026: ~104,592May 2026: ~155,388Apr 2026: ~85,620Mar 2026: ~128,256Feb 2026: ~133,968at: Oct 2026top: 0.00M pxtotal: 9.29M pxloaded: 2 / 240photos: 3,754 hereanchor: on~ means estimatedhatched: not loaded
Months 1 and 2 are loaded. Drag the rail far down, then let go to load the sections there.

Check your understanding · Exercise 5

A user drags the scrubber from 2024 down to 2012 in one second without pausing, then lets go. Which behaviours are right? Select all that apply.

Loading and caching metadata and thumbnails

Two kinds of data flow into the grid: section metadata (small JSON, needed for layout) and thumbnails (large images, needed for pixels). Both must follow the viewport, both must be cancellable, and thumbnails must never compete with the photos the user is looking at right now.

Metadata: summary first, sections on demand

On open we fetch the summary (about 300 sections, under 10 KB gzipped) and the metadata of the first two sections. Further sections are fetched when they come within about two screens of the viewport, in the scroll direction first. Section responses are compact: parallel arrays of ids, ratios, and times instead of an object per photo, which roughly halves the raw JSON (estimate). The natural pagination cursor is the section id, because sections are stable buckets of time.

Thumbnails sized by the image service

Each item has a base URL on an image service that resizes on request. The Google Photos Library API documents this style: append size parameters such as =w560-h400 to the base URL and the server returns that size. We request the tile's CSS size times devicePixelRatio, rounded up to a bucket (heights in steps of 50 pixels, for example), so small differences between rows and after resizes hit the same cached file on the CDN. With Accept negotiation the service can return AVIF or WebP.

Decision How do thumbnails get loaded, prioritised, and cancelled?

Plain img with loading=lazy

Render an img per mounted tile with loading="lazy" and let the browser fetch when near the viewport.

Pros

  • No code; the browser handles distance thresholds.

Cons

  • No notion of scroll speed: during a fling every tile that flashes past starts a request.
  • No control over order or how many are in flight; offscreen requests compete with visible ones.

Our own scheduler feeding img elements

Tiles register with a scheduler; it sets src only for visible tiles first, then the next screen in the scroll direction, a batch of about 10 at a time.

It measures scroll speed: above a threshold it stops starting full thumbnails and shows only placeholders.

Tiles that unmount lose their img, which frees the decoded image and may let the browser drop a pending request.

Pros

  • Visible first, direction aware, speed aware.
  • Keeps the browser's image cache, decoding="async", and fetchpriority hints.

Cons

  • Cancelling by removing an img is best effort, not guaranteed.
  • A scheduler to write and tune.

fetch plus createImageBitmap in a worker

A worker fetches each thumbnail with an AbortController, decodes it with createImageBitmap, and sends the bitmap to the page to draw on a canvas.

Pros

  • Real cancellation and decoding fully off the main thread.

Cons

  • Canvas tiles lose alt text, selection styling, and right-click save; each bitmap must be closed by hand to free memory.
  • Much more code for a gain the browser mostly gives you already.

What we pick: Our own scheduler feeding normal img elements with decoding="async". Speed awareness is the part that matters most, and it does not need canvas. Keep the worker idea for the full-screen viewer, where one huge image is decoded at a time (the image lightbox guide covers that).

Placeholders in three steps

A tile goes through up to three looks. First, the dominant colour from the metadata, painted as the tile's background: zero requests. Second, an optional tiny preview: a ThumbHash or BlurHash (a few dozen bytes that encode a blurry version of the image) stored in the metadata and decoded on the client, or a very small, low quality image from the image service. Third, the real thumbnail, faded in over about 100 ms so it does not flash.

The Google Photos post describes loading tiny low quality placeholders for up to 4 or 5 screens ahead: in its example, 889 bytes for a placeholder against 71.2 KB for a high density thumbnail. During a fast fling the grid shows only colours and tiny previews, which still gives the eye enough to recognise a beach or a birthday.

State machine of one grid tile. Unmounted goes to colour when the tile is mounted. Colour goes to preview when scroll speed drops under 20 pixels per millisecond. Preview goes to full thumbnail when speed drops under 6 pixels per millisecond. From full thumbnail, an error goes to failed, and failed goes back to full thumbnail on a retry while the tile is in view. From any state, leaving the overscan area goes back to unmounted, removing the img.unmountedcolourpreviewfull thumbfailedmountedunder 20 px/msunder 6 px/mserrorretryleaves overscan: img removed
Each step costs more bytes, so a tile only climbs as the scroll slows. Leaving the overscan area always returns to unmounted, which frees the decoded pixels.
ts
function onFrame(now: number) {
  const speed = Math.abs(scroller.scrollTop - lastTop) / (now - lastTime); // pixels per ms
  lastTop = scroller.scrollTop; lastTime = now;

  const mode = speed > 20 ? "colour-only" : speed > 6 ? "preview" : "full";
  for (const tile of grid.visibleTiles()) tile.want(mode === "full" ? "thumb" : mode === "preview" ? "preview" : "colour");
  if (mode === "full") {
    for (const tile of grid.nextScreen(direction)) tile.want("thumb", "low");
  }
  scheduler.flush({ maxInFlight: 10 }); // visible first, then lower priority
  requestAnimationFrame(onFrame);
}
The speed thresholds are starting points to tune with real traces, not standard values.

Decoding is the hidden cost. A 560 by 400 thumbnail can take a few milliseconds to decode, and 25 decodes in one frame would blow the frame budget. decoding="async" lets the browser decode off the critical path so the frame can paint without waiting, and img.decode() returns a promise that resolves once the image is ready to paint, which is a clean moment to start the fade in.

Keep the DOM changes per frame small: mount new rows, set a few src values, and let the scheduler spread the rest over the next frames. The runtime performance and reflow and repaint lessons explain the frame budget in detail.

Caching for an instant open

Most visits are returns: the same person opening the same library. If the second open must download the summary and the first sections again, the grid is blank for a round trip or two. Caching the right things locally makes the warm open paint from disk in a few hundred milliseconds, then quietly check for changes.

Decision What do we cache, and where?

HTTP cache only

Long Cache-Control on thumbnails; ETag on the summary and sections.

Pros

  • No code; thumbnails by immutable URL are a perfect fit.

Cons

  • The browser decides eviction; JSON still needs a network round trip to revalidate before the grid can render.

IndexedDB for metadata, HTTP cache for thumbnails

Store the summary and recently viewed sections in IndexedDB with their versions.

On open, render from IndexedDB at once, then fetch the summary with If-None-Match and refresh only sections whose version changed.

Pros

  • The grid renders before the network answers.
  • Section versions make revalidation cheap: one summary request tells us which sections changed.

Cons

  • A second copy of data to keep in sync; must be cleared on sign out.
  • If base URLs are signed and expire (the Photos Library API's expire after 60 minutes), cached sections need fresh URLs before their thumbnails load.

Also a service worker with a Cache API thumbnail cache

A service worker serves thumbnails cache first from a named cache with our own size cap, for example the 3,000 most recent thumbnails, about 120 MB (estimate).

Pros

  • Predictable: we choose what stays, such as the most recent months.
  • Allows a read-only offline view of recent photos.

Cons

  • We must enforce the cap ourselves; the Cache API has no built-in eviction by size.
  • Another moving part to version and debug.

What we pick: IndexedDB for metadata from day one, the HTTP cache for thumbnails, and a service worker thumbnail cache as a later step if offline viewing of recent months is a requirement. The metadata cache gives most of the speed win for little risk.

Storage limits matter at this size. Browsers give an origin a share of free disk (the client storage lesson covers the numbers per browser), and data can be evicted under pressure unless navigator.storage.persist() is granted. Treat everything here as a cache that can vanish: the app must work, only slower, if IndexedDB is empty.

On sign out, delete the database and the caches: a shared family computer must not show the previous user's photos. The IndexedDB and service worker caching lessons cover the APIs; the offline sync engine guide covers queued offline writes, which this design does not need beyond the upload queue.

Check your understanding · Exercise 6

During a fast fling the network panel shows 400 thumbnail requests, most finishing after their tiles left the screen. Which fixes help? Select all that apply.

Selection at scale: ranges, not lists of ids

Selection looks simple until the numbers arrive. A user clicks a photo from a 2019 trip, scrolls to 2021 with the rail, and shift-clicks. Between the two clicks sit 9,000 photos, and the client has metadata for maybe 400 of them.

A selection stored as a set of ids cannot even be built: the ids are not on the client. Even if they were, loading two years of sections just to list them, then sending 9,000 ids in one request, is wasteful.

Decision How do we represent what is selected?

A Set of ids

Click adds or removes an id; shift-click adds every loaded id between the anchor and the target.

Pros

  • Simple; easy to check one tile with has.

Cons

  • Cannot cover unloaded items, so shift-click across months either fails or forces loading every section in between.
  • Bulk requests carry thousands of ids.

Ranges in timeline order, plus exceptions

Every item has a sort key: local capture day, then capture instant, then id to break ties. The timeline is sorted by it.

A selection is a list of key ranges, plus a set of ids added one by one and a set of ids removed from inside ranges.

Pros

  • Shift-click across 9,000 unloaded items is one small object.
  • The server can resolve the same ranges with an index scan.

Cons

  • Checking one tile means a binary search over ranges, and the count needs the section counts.
  • New items arriving inside a range raise the question of whether they are selected.

A query predicate

Store the selection as a filter, such as all items between two dates matching a search.

Pros

  • Handles select all in a search result of 30,000 items.

Cons

  • Too coarse for hand-picked items; you end up adding exceptions anyway, which is the second option.

What we pick: Ranges in timeline order plus include and exclude sets. It covers clicks, shift-clicks over unloaded months, and select a whole day with one model, and it is the same shape the bulk action API accepts.

ts
type SortKey = { day: string; t: number; id: string }; // local day, then capture instant in ms, then id
type Range = { from: SortKey; to: SortKey }; // inclusive, from is newer

type Selection = {
  ranges: Range[];          // sorted, non-overlapping
  include: Set<string>;     // single picks outside ranges
  exclude: Set<string>;     // unticked items inside ranges
  anchor: SortKey | null;   // last plain click, for shift-click
  asOf: string;             // library version when the selection started
};

export function isSelected(sel: Selection, key: SortKey): boolean {
  if (sel.exclude.has(key.id)) return false;
  if (sel.include.has(key.id)) return true;
  return findRange(sel.ranges, key) !== -1; // binary search
}

The count shown in the selection bar ("9,214 selected") is computed on the client without loading anything. The two ends of a shift-click range are loaded (the user clicked them), so we count exactly inside those two sections, add the summary counts of every whole section in between, then add the include set and subtract the exclude set. Select a whole day is a range from the day's first to last key. Select all in a search result is a predicate the server understands, sent the same way.

Bulk actions as server jobs

Deleting 9,214 photos is not 9,214 requests, and not one request that holds a connection for a minute. The client posts the selection object to a jobs endpoint and gets back a job id at once. It applies the change optimistically (selected tiles disappear and the section counts drop), shows a progress toast, and polls the job or listens on the live channel. On success it commits; on failure it restores the items and shows what failed.

The asOf version matters. A photo that finished uploading inside the range after the user started selecting should not be deleted by surprise, so the server resolves the ranges against the library as it was at that version.

Sequence of a range selection and a failed bulk delete, between the user, the selection store, the grid view, and the API. The user clicks a photo from 3 May 2019, then shift-clicks one from 18 August 2021. The selection store sends the grid a range and a count of 9,214. The user presses Delete. The grid posts a job to the API with the selection object, and gets 202 with a job id. The grid hides the 9,214 tiles at once. Later the API reports job failed, and the grid restores the tiles and shows an error.UserSelection storeGrid viewAPIclick 3 May 2019shift-click 2021range, 9,214DeletePOST /v1/jobs202 job_51tiles hiddenjob.failedrestored, error
The range never becomes a list of ids on the client, so it works across months that were never loaded. The tiles disappear before the server finishes, and come back if the job fails.

Keyboard selection follows the same model. Arrow keys move focus between tiles using the layout positions: right and left go to the next and previous item in time order, up and down go to the item in the row above or below that is nearest in x. Space toggles the focused item, Shift with an arrow extends a range from the anchor, and Escape clears the selection. The accessibility chapter covers how these are announced.

Check your understanding · Exercise 7

A user shift-clicks from 3 May 2019 to 18 August 2021, then unticks two photos in the middle. What does the client send for "Add to album"? Pick one.

Uploads that land in the past

The file uploader guide covers moving bytes: chunks, resumable sessions, retries, and progress. This chapter covers what is special about uploading into a library.

A user drops a folder of 5,000 photos from an old hard drive. Many are already in the library from a phone backup. Their dates span 2006 to 2012, so they belong deep in the timeline, not at the top, and none is usable until the server has made thumbnails.

De-duplicate before sending a byte

Decision Where do we detect files the library already has?

On the server, after upload

Upload everything; the server hashes and drops copies.

Pros

  • No client work.

Cons

  • If 3,000 of 5,000 files at 4 MB each are copies, the user waits for 12 GB of wasted upload.

Client hash, then a batch check

A worker computes SHA-256 of each file with crypto.subtle.digest.

The client sends hashes in batches of 500 to a check endpoint; the server answers which it already has.

Pros

  • Copies cost a hash and a few bytes of request, not an upload.
  • Hashing runs in a worker, so the page stays responsive.

Cons

  • Hashing reads every file once: about 20 GB for 5,000 photos is minutes of disk reads.
  • crypto.subtle.digest takes the whole buffer at once; large videos need an incremental hash from a library.

Match on metadata only

Compare file name, size, and capture time with what the library has.

Pros

  • Instant, no reading.

Cons

  • False matches (two cameras named IMG_0001.JPG with the same size) silently skip real photos. Losing a photo is the worst bug this product can have.

What we pick: Client hash plus batch check. It is exact, and its cost is local reading, which is much faster than uploading. Hash in a worker two files at a time while earlier files are already uploading, so the pipeline never stalls.

Finding the capture date, and its time zone

For JPEG files the capture time is in the EXIF block (the metadata cameras write near the start of the file). The tag DateTimeOriginal holds the local time on the camera clock as text like 2014:08:16 18:42:07, with no time zone. Exif 2.31 (2016) added OffsetTimeOriginal, a string like +05:30; newer phones write it, older cameras do not.

The client can read the first 128 KB of each file (file.slice(0, 131072); the EXIF segment is at most 64 KB and comes near the start), parse these tags in the worker, and place a placeholder at the right date at once. HEIC photos and videos keep their dates in other container formats that need other parsers, so for those the client uses the file's lastModified as a temporary date and moves the item when the server reports the real one.

Which day does a photo belong to? Group by the local date where it was taken, not the viewer's time zone. A photo taken at 23:30 on New Year's Eve in Tokyo should be on 31 December for a viewer in London too, even though it was 14:30 in London.

So each item stores both a local date and an instant (UTC). The timeline orders by local date first and by instant within a day, so what the user sees and the selection sort key always agree. When the offset is missing, the server can infer it from the GPS position; otherwise it treats the camera time as local. The locale formatting lesson covers showing these dates in the user's language.

From file to photo: the states of one upload

State machine of one upload. Queued goes to hashing when a worker reads the file. Hashing goes to duplicate when the hash is already in the library, or to uploading when it is new. Uploading goes to processing when the bytes are done, or to failed on an error; failed goes back to uploading on retry. Processing goes to ready when the server has made thumbnails. Notes: 5,000 queued items are nearly free; 2 files hash and 4 upload at a time; the tile shows at its capture date from uploading onward.queuedhashinguploadingprocessingduplicatefailedreadyalready in libraryerrorretrythumbs madenew hash5,000 queued: nearly free2 hashing, 4 uploadingtile at capture datefrom uploading on
Duplicates leave the pipeline before any bytes move. From uploading onward, the item already has a tile at its capture date, so the user sees where it will land.
  • Queued: the file is listed in the upload panel. Nothing is read yet, so 5,000 queued items cost almost nothing.
  • Hashing and reading the date: in the worker, two at a time.
  • Duplicate: the check says the library has it. Skipped, counted in the summary ("3,104 already in your library").
  • Uploading: bytes move with the resumable protocol, up to 4 files in parallel. The tile appears in the timeline at its capture date with a progress ring, using a small local preview made with createImageBitmap(file, { resizeWidth: 400 }) (where the resize option is supported) only when the tile is visible.
  • Processing: the server stores the original, reads metadata, and makes thumbnails. The tile shows a spinner; the live channel or a poll reports when it is ready.
  • Ready: the placeholder is replaced by the real item, possibly in a different place if the server's date differs. Failed: the tile shows a retry button and the panel lists the error.

Uploads that land in 2009 are invisible if the user is looking at today. The upload panel lists them with a "Show in timeline" link that scrolls to their date with the same jump the scrubber uses, and a "Recently added" view lists them in upload order. Warn before leaving with a beforeunload handler while bytes are moving, and persist the queue (file handles where the File System Access API is available, otherwise the list of names) in IndexedDB so a reload can offer to resume.

Check your understanding · Exercise 8

A photo's EXIF says DateTimeOriginal 2019:12:31 23:30:00 and OffsetTimeOriginal +09:00. The viewer is in London. Which statements are right? Select all that apply.

Search, albums, videos, and sharing

These features are big products of their own, but on the client most of them reuse the grid. The design question is what the grid engine needs to accept so that the timeline, a search result, an album, and a person's page are all the same component with a different data source.

Search by "dog on a beach", by a person's name, or by a place depends on image understanding that runs on the server over the whole library. Apple runs much of this analysis on the device for iCloud Photos, but shipping models and indexing 200,000 photos in a browser tab is not realistic, so treat on-device search as out of scope.

The client owns three things. First, the query box: suggestions as you type, grouped into people (round face crops, the one place crops are fine), places, things, and dates, with the debounce and stale response handling from the request races lesson. Second, results: another justified grid. Third, the URL: the query lives in it, so results can be shared and the back button works.

Results sorted by date fit the section model directly: the server returns a summary of result counts per month, and the scrubber works as on the main timeline. Results sorted by relevance have no dates to scrub, so the grid uses pages of a few hundred results as sections, with estimated heights from the counts, and the rail falls back to a plain scrollbar.

So the grid engine takes a data source with a few methods: get the summary, get one section's items, describe a position (a date or "result 1,200") for the tooltip, and give each item its sort key for selection.

tsx
interface GridSource {
  summary(signal: AbortSignal): Promise<SectionSummary[]>;
  section(id: string, signal: AbortSignal): Promise<SectionItems>;
  label(sectionId: string): string; // "March 2015" or "Results 1,201 to 1,500"
  sortKeyOf(item: ItemMeta): SortKey; // for selection ranges
}

// Timeline, search, album, person, place: same grid, different source
<PhotoGrid source={timelineSource} />
<PhotoGrid source={searchSource(query)} scrubber={query.sort === "date"} />

Albums and sharing, briefly

An album is an ordered list of item ids, sorted by date or by hand, so it is a grid source too. Adding 3,000 selected photos to an album is a bulk job with the selection object from the selection chapter. Sharing creates a token on the server, and viewers open a read-only grid that loads through it. Permissions, link expiry, and revoking access work as in the Google Drive guide.

Videos and live photos in the grid

In the grid a video is a still poster thumbnail with a duration badge ("0:42") from its metadata, laid out by its aspect ratio like any photo. Hovering for about 300 ms starts a short, muted preview: a low resolution clip played in a video element with muted, playsinline, and loop, created only for the hovered tile and removed on leave. Only one preview plays at a time.

Live photos (a still plus about three seconds of motion) get a small badge and play their clip the same way. Skip the previews when prefers-reduced-motion: reduce is set or the user has turned on Save-Data, and never autoplay during scrolling. Playing the full video belongs to the viewer.

Check your understanding · Exercise 9

Which parts of search belong in the web client? Select all that apply.

Architecture, data model, and API

With the hard parts designed, we can draw the boxes. The main idea is that the grid engine is plain TypeScript with no framework: it owns layout, section heights, tops, the visible window, and the anchor. React (or any view layer) renders only what the engine says is in the window. That boundary keeps per-frame work out of the component tree and makes the engine testable with numbers alone.

Architecture of the web client. In the browser tab: the Grid view (sections and tiles) and the Scrubber with the selection bar sit on top. Below them, the Grid engine owns layout, section tops, the window, and the anchor; it sends the window up to the Grid view, and the Scrubber sends scrollTop down to it. Below the engine: the Library store gives counts and ratios to the engine, beside the Selection store and the Thumbnails scheduler. At the bottom: IndexedDB feeds the Library store as a cache, the Upload worker hashes files and reads EXIF and hands them to the API client, and the API client fills the Library store. On the right, outside the tab, the Thumbnails scheduler loads from the CDN, and the API client talks REST and SSE to the API.Browser tabGrid viewsections, tilesScrubberand selection barGrid enginelayout, section tops, window, anchorLibrary storeSelectionThumbnailsIndexedDBUpload workerhash, EXIFAPI clientwindowscrollTopcounts, ratioscacheServersCDNAPIREST, SSE
The engine sits between the views and the data, and runs every frame without React. Stores hold small, long-lived state. Everything that touches the network or the disk is at the bottom, so it can be cached, retried, or moved to a worker.
PartOwnsWhy the boundary is here
Grid engineJustified layout per day, section heights and tops, the visible window, scroll anchoring, keyboard geometryRuns every frame; must not wait on React renders. Pure functions of numbers, so unit tests cover it.
Grid view (Section, Row, Tile)DOM for mounted sections, tiles, checkmarks, badges, focusRenders only when the window changes (a row enters or leaves), not on every scroll pixel.
ScrubberRail, labels, tooltip, drag, slider semanticsReads tops from the engine and writes scroll position; never fetches by itself.
Library storeSummary, section items cache, item metadata by id, library versionOne source of truth for every grid source and for counts.
Selection storeRanges, include and exclude sets, anchor, asOfTiny state that many tiles read; checking one tile is a binary search.
Thumbnail schedulerRequest queue, priorities, batch size, speed modeSeparates what the user sees from what is fetched; one place to tune.
Upload queue and workerFile list, hashing, EXIF reading, transfers, statesHashing gigabytes must not touch the main thread.
API client and live channelREST calls with AbortSignal, job polling, a server-sent events stream for new items and job progressOne place for auth, retries, and error shapes.
PersistenceIndexedDB for summary and sections, Cache API for thumbnailsMakes a warm open instant; see the caching part of the loading chapter.

Data flows in one direction on reads. The summary and section items come from the API (or from IndexedDB first, then the API to revalidate) into the library store. The engine reads counts and ratios from the store, computes layout, and publishes the window; the view renders it, and tiles ask the scheduler for thumbnails from the CDN.

Writes go the other way: a bulk action or upload changes the stores optimistically, the API client sends the job, and the live channel confirms it or the client rolls back.

Data flow for the timeline. The API sends the summary, then sections, and later SSE events, to the Library store. IndexedDB, on the right, gives the Library store a cached copy for a warm open. The Library store gives counts and ratios to the Grid engine. The engine gives the window, the rows to mount, to the Grid view. The Grid view requests thumbnails by URL from the CDN and image service, which returns image bytes. Writes go back from the Grid view to the API as POST jobs.APIsummary, sections, jobsIndexedDBLibrary storeGrid engineGrid viewCDNimage servicesummary, sections, eventscachecounts, ratiosrows to mountthumb URL, bytesPOST /jobs
JSON flows down a narrow path: summary first, then one section at a time. Image bytes take a separate path straight from the CDN to the tiles, and writes go back to the API as jobs.

Data model: what lives where

The client holds state of very different sizes and lifetimes. Getting each kind's home right is what keeps memory flat.

ts
type SectionSummary = {
  id: string;            // "2015-03" or "2015-03-b" for a split heavy month
  start: string; end: string; // local dates, newest first
  count: number;
  days: number;
  version: number;       // bumps when items are added or removed
};

type SectionItems = {   // parallel arrays: about half the JSON of one object per item
  sectionId: string;
  ids: string[];
  t: number[];           // capture instant, ms since epoch: order within a day
  localDay: string[];    // "2015-03-14": grouping key and first sort key
  ratio: number[];       // width / height, 3 decimals
  kind: ("photo" | "video" | "live")[];
  durationMs: (number | null)[];
  colour: string[];      // dominant colour, "#6b8fa3"
  hash: (string | null)[]; // ThumbHash, base64, optional
  baseUrl: string[];
};

type SectionLayout = { width: number; target: number; height: number; days: DayLayout[] };
type DayLayout = { day: string; top: number; rows: { top: number; height: number; start: number; end: number }[] };
StateSize for 200,000 itemsLives inLifetime
Section summariesAbout 300 rows, about 30 KB rawLibrary store, mirrored to IndexedDBWhole session; revalidated on open by version
Section itemsAbout 250 bytes per item in memory (estimate); only sections near the viewport, up to about 20,000 itemsLibrary store as an LRU of sections; IndexedDB keeps moreEvicted from memory when far away; IndexedDB evicts by age
Section layoutsA few numbers per rowGrid engine, keyed by section and widthThrown away on resize, recomputed for visible sections
ThumbnailsAbout 40 KB each on disk, 0.9 MB decodedBrowser image cache and Cache API; decoded only while mountedDisk: capped by our cache budget; memory: while in view
SelectionBytes to a few KBSelection store; not persistedUntil cleared or the page changes
Upload queueOne small record per fileUpload store, mirrored to IndexedDBUntil each item is ready or skipped
Scroll date, search query, open itemOne short string eachURLShareable and survives reload
Client data model. SectionSummary, about 300 rows kept in IndexedDB, points by id to SectionItems, an LRU of about 20,000 items, whose ratios feed SectionLayout, computed per width by the engine. Below: the Selection holds ranges, include, and exclude sets and refers to items by sort key; Upload items are a queue in IndexedDB; the URL holds the date, the search query, and the open item. A note: memory is about 5 MB of metadata plus about 67 MB of decoded tiles, both estimates.SectionSummary300 rows, IndexedDBSectionItemsLRU, 20,000 itemsSectionLayoutper width, engineSelectionranges + exceptionsUpload itemsqueue in IndexedDBURLdate, query, itemidratiossort keysmemory: about 5 MB metadata + 67 MB decoded tiles (estimates)
Only SectionItems grows with how far the user travels, and an LRU caps it. Everything else is small or lives outside memory.

The memory budget follows from the table. Keeping at most about 20,000 items of metadata in memory (around 5 MB, estimate) plus 75 mounted tiles (about 67 MB decoded) stays well under the 300 MB target, whatever the library size. The LRU of sections is the one knob: if the user scrubs through 20 years, older sections fall out of memory, and IndexedDB makes them cheap to bring back.

API: summaries, sections, and jobs

The API is shaped around sections, not pages of photos. Every list endpoint is either a summary or one section, and every write that touches many items takes a selection object and returns a job.

http
GET /v1/library/summary
If-None-Match: "lib-v8790"

200 OK
ETag: "lib-v8812"
{ "version": 8812,
  "sections": [
    { "id": "2026-10", "start": "2026-10-22", "end": "2026-10-01", "count": 412, "days": 19, "version": 77 },
    { "id": "2026-09", "start": "2026-09-30", "end": "2026-09-01", "count": 1630, "days": 30, "version": 75 }
  ] }

GET /v1/library/sections/2015-03?fields=ids,t,localDay,ratio,kind,durationMs,colour,hash,baseUrl
200 OK   (SectionItems, about 45 KB gzipped for 600 items, estimate)
A 304 Not Modified on the summary makes a warm open cost one tiny round trip.
http
POST /v1/jobs
Idempotency-Key: 6f1c2a
{ "action": "trash",
  "selection": {
    "ranges": [{ "from": { "day": "2021-08-18", "t": 1629300000000, "id": "a9" },
                 "to": { "day": "2019-05-03", "t": 1556870000000, "id": "k2" } }],
    "include": [], "exclude": ["m4", "q7"], "asOf": 8812 } }

202 Accepted
{ "jobId": "job_51", "resolvedCount": 9214 }

GET /v1/jobs/job_51  ->  { "state": "running", "done": 4100, "total": 9214 }

POST /v1/uploads/check  { "sha256": ["9f86d0...", "..."] }
200 OK  { "existing": ["9f86d0..."] }
The idempotency key makes a retried POST safe: the server returns the same job instead of starting a second one.
  • Errors use one shape: { "error": { "code": "SECTION_GONE", "message": "...", "retryable": false } }. SECTION_GONE means the section was split or merged since the summary was fetched: refetch the summary.
  • The live channel is a server-sent events stream: section.changed with section id and new count and version, job.progress, job.done, and upload.ready with the final item. On reconnect the browser sends Last-Event-ID, so the server can replay what was missed.
  • Thumbnails do not go through the API: tiles build URLs from baseUrl plus a size suffix and load them from the CDN, cacheable for a long time because a given URL never changes content.

The grid component itself has a small surface: source (the data source), targetRowHeight, selection and onSelectionChange, onOpen(itemId) to open the viewer, and scrubber on or off. Everything else, such as layout and loading, is internal.

Check your understanding · Exercise 10

Why keep the grid engine outside the React component tree? Select all that apply.

Accessibility: a grid of thousands of images

A screen reader user and a keyboard user need to browse by date, move between photos, select, and act, without hearing "image, image, image" 200,000 times.

Decision Which ARIA structure fits the photo grid?

role=grid with rows and gridcells

Each justified row is a row, each photo a gridcell; arrow keys move in two dimensions, per the WAI-ARIA grid pattern.

Pros

  • Arrow key movement up and down matches the visual rows.

Cons

  • Rows change with window width, so the row and column numbers announced change after a resize, and rows have different column counts.
  • Screen readers handle large virtualized grids unevenly; aria-rowcount must be estimated.

Sections as headed groups with a list of photos

Each day is a section with a heading ("Saturday, 14 March 2015, 23 items"); the photos are a list of items with one focusable element each.

Arrow keys still move in two dimensions using the engine's geometry; Tab moves into and out of the grid with a roving tabindex.

Pros

  • Heading navigation lets screen reader users jump by day, which is how people think about photos.
  • Nothing announced depends on window width.

Cons

  • Two-dimensional arrow keys are a custom behaviour that must be explained once, for example in the grid's description.

What we pick: Headed day sections with lists, plus our own two-dimensional arrow keys. The justified rows are a visual artefact of the window width, while days are meaningful. Either structure is defensible in an interview if you explain why.

  • Alt text: the caption if the user wrote one, otherwise kind and date: "Photo, 14 March 2015, 6:42 PM" or "Video, 42 seconds, 14 March 2015". Generated descriptions from the server are a bonus where available.
  • Focus survives virtualization: the focused tile is never unmounted while focused; if keyboard movement goes past the mounted rows, the engine mounts the target row first, then moves focus.
  • Selection changes are announced in a polite live region: "9,214 selected". Debounce it so a fast Shift and arrow run does not read every number.
  • The selection checkmark on each tile is a real control with aria-pressed (or a checkbox), visible on focus, not only on hover.
  • Upload progress uses a progressbar with a text value, and completion is announced once per batch, not per file.

Test it the way people use it: tab into the grid, arrow through a day, shift-select a range, and delete, with NVDA or VoiceOver running. The ARIA patterns and keyboard navigation lessons cover roving tabindex and live regions in detail. One more trap is specific to this grid: the heading of a day that is only partly mounted must still be in the DOM, or heading navigation skips it.

Check your understanding · Exercise 11

A keyboard user presses the down arrow on a photo in the last mounted row. What should happen? Select all that apply.

Running it in the interview

Sixty minutes is not enough to cover every chapter, so choose the depth deliberately. The grid, the sections, and the scrubber are the core: they are where this problem differs from every feed question. Spend most of the time there and touch the rest.

MinutesWhat to doWhat to draw or say
0 to 5Scope questions: library size, the rail, aspect ratios, selection, uploads"200,000 items over 20 years, random access with a rail"
5 to 10Size mathSummary under 10 KB versus metadata 50 MB; 25 tiles per screen; 0.9 MB decoded per tile
10 to 20Justified layout per dayThe row height formula, greedy versus optimal breaks, why not CSS
20 to 32Sections and the scrubberEstimated heights from counts, prefix tops, the anchor correction, the drag sequence
32 to 40LoadingSummary then sections on demand; thumbnail scheduler with speed modes and placeholders
40 to 48Selection and bulk jobsRanges plus exceptions; jobs with asOf; optimistic removal and rollback
48 to 55Architecture and APIEngine outside React; summary, section, jobs, uploads/check endpoints
55 to 60Uploads, caching, accessibility, and questionsHash before upload, local capture dates, IndexedDB warm open, headed day sections

Draw the wireframe first (rows, day headings, the rail), then the scroll space as a column of sections with a bracket for the viewport: that one picture carries the estimate, the window, and the anchor. Say the numbers as you draw them. When the interviewer pushes on one area, go deep there and skip a later row of the table; a strong answer on sections and the scrubber beats a thin tour of everything.

Think first

Follow-up: the user resizes the window from 1440 to 1000 pixels wide. What happens?

Think first

Follow-up: a professional photographer has 2 million items. What breaks first?

Think first

Follow-up: the phone uploads 300 photos to today's section while the user is viewing 2015. What does the web app do?

Summary

  • A personal library is deep and accessed at random. Fetch a small summary of section counts first, give every section an estimated height, and fetch real metadata only near the viewport.
  • The justified grid is computed in JavaScript, per day, with optimal row breaks: the positions are needed for virtualization, the scrubber, and keyboard navigation.
  • Estimates are corrected as sections load. Keep the view still by anchoring the first visible tile and adjusting scrollTop in the same frame, with browser scroll anchoring turned off for the grid.
  • The scrubber maps rail position to content height, shows dates from the summary while dragging, and fetches only where the pointer stops.
  • Thumbnails are sized to the row height times the device pixel ratio, scheduled visible first in small batches, and replaced by colours or tiny previews during fast scrolling.
  • Selection is ranges in timeline order plus exceptions, so shift-click over unloaded months works; bulk actions are server jobs with an asOf version.
  • Uploads are hashed and checked before sending, placed at their local capture date, and tracked through queued, uploading, processing, and ready states.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next