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.
| Question | Answer we assume | What it changes |
|---|---|---|
| How big is a large library? | 50,000 typical heavy user, 200,000 for the top 1 percent, spanning up to 20 years | Metadata 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 headings | Layout 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 width | A justified layout, computed in JavaScript, not CSS |
| How do people jump far back? | A date rail (the scrubber) on the right, drag to any month | Every 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, download | Selection must cover photos that were never loaded |
| Uploads from the web? | Yes: drop a folder of up to 5,000 files | A queue, de-duplication, and placeholders at the capture date |
| Search? | By place, person, thing, and date; the server does the understanding | Search 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 app | Thumbnail 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.
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.
| Thing | For 200,000 items | What we do |
|---|---|---|
| Section summary (about 300 sections) | About 30 KB raw, under 10 KB gzipped (estimate) | Fetch on open, cache in IndexedDB |
| Full metadata | About 50 MB JSON (estimate) | Fetch per date range, only near the viewport |
| Thumbnail bytes | About 1 MB per screen at DPR 2 (estimate) | Size to the row height, cancel when scrolled away |
| Decoded pixels | About 0.9 MB per tile (estimate) | Only visible tiles plus overscan exist |
| DOM elements | 800,000 without windowing | Under 500 with section virtualization |
| Scroll height | About 8.3 million pixels | One spacer element; sections absolutely positioned |
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?
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.
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.
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;
}
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.
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.
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.
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;
}
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?
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.
scrollTop in the same frame keeps the anchor tile exactly where the user saw it.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;
}
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?
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
pointerdownon the rail captures the pointer (setPointerCapture) so the drag continues if the pointer leaves the rail.- Each
pointermoveupdates the handle and the tooltip immediately: binary searchtopsfor the section at that scroll position and show its month and year. No network yet. - 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.
- 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 withAbortController, so a drag across 10 years never fires 120 section requests. - When metadata arrives, lay out the section, apply height corrections with the anchor rule from the previous chapter, then request thumbnails for visible rows.
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?
Yes, but quietly. Writing the date of the anchor section to the URL with history.replaceState (not pushState) after the scroll settles lets a reload or a shared link reopen at the same month, without filling the back button history with every drag.
On load with a date in the URL, the grid fetches the summary, scrolls to that date's estimated position, and loads that section first. The anchor rule keeps it in place as everything else settles.
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.
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?
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.
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);
}
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?
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?
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.
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.
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?
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
- 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 is a server feature with a client shape
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.
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.
| Part | Owns | Why the boundary is here |
|---|---|---|
| Grid engine | Justified layout per day, section heights and tops, the visible window, scroll anchoring, keyboard geometry | Runs 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, focus | Renders only when the window changes (a row enters or leaves), not on every scroll pixel. |
| Scrubber | Rail, labels, tooltip, drag, slider semantics | Reads tops from the engine and writes scroll position; never fetches by itself. |
| Library store | Summary, section items cache, item metadata by id, library version | One source of truth for every grid source and for counts. |
| Selection store | Ranges, include and exclude sets, anchor, asOf | Tiny state that many tiles read; checking one tile is a binary search. |
| Thumbnail scheduler | Request queue, priorities, batch size, speed mode | Separates what the user sees from what is fetched; one place to tune. |
| Upload queue and worker | File list, hashing, EXIF reading, transfers, states | Hashing gigabytes must not touch the main thread. |
| API client and live channel | REST calls with AbortSignal, job polling, a server-sent events stream for new items and job progress | One place for auth, retries, and error shapes. |
| Persistence | IndexedDB for summary and sections, Cache API for thumbnails | Makes 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 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.
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 }[] };
| State | Size for 200,000 items | Lives in | Lifetime |
|---|---|---|---|
| Section summaries | About 300 rows, about 30 KB raw | Library store, mirrored to IndexedDB | Whole session; revalidated on open by version |
| Section items | About 250 bytes per item in memory (estimate); only sections near the viewport, up to about 20,000 items | Library store as an LRU of sections; IndexedDB keeps more | Evicted from memory when far away; IndexedDB evicts by age |
| Section layouts | A few numbers per row | Grid engine, keyed by section and width | Thrown away on resize, recomputed for visible sections |
| Thumbnails | About 40 KB each on disk, 0.9 MB decoded | Browser image cache and Cache API; decoded only while mounted | Disk: capped by our cache budget; memory: while in view |
| Selection | Bytes to a few KB | Selection store; not persisted | Until cleared or the page changes |
| Upload queue | One small record per file | Upload store, mirrored to IndexedDB | Until each item is ready or skipped |
| Scroll date, search query, open item | One short string each | URL | Shareable and survives reload |
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.
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)
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..."] }
- Errors use one shape:
{ "error": { "code": "SECTION_GONE", "message": "...", "retryable": false } }.SECTION_GONEmeans the section was split or merged since the summary was fetched: refetch the summary. - The live channel is a server-sent events stream:
section.changedwith section id and new count and version,job.progress,job.done, andupload.readywith the final item. On reconnect the browser sendsLast-Event-ID, so the server can replay what was missed. - Thumbnails do not go through the API: tiles build URLs from
baseUrlplus 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?
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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope questions: library size, the rail, aspect ratios, selection, uploads | "200,000 items over 20 years, random access with a rail" |
| 5 to 10 | Size math | Summary under 10 KB versus metadata 50 MB; 25 tiles per screen; 0.9 MB decoded per tile |
| 10 to 20 | Justified layout per day | The row height formula, greedy versus optimal breaks, why not CSS |
| 20 to 32 | Sections and the scrubber | Estimated heights from counts, prefix tops, the anchor correction, the drag sequence |
| 32 to 40 | Loading | Summary then sections on demand; thumbnail scheduler with speed modes and placeholders |
| 40 to 48 | Selection and bulk jobs | Ranges plus exceptions; jobs with asOf; optimistic removal and rollback |
| 48 to 55 | Architecture and API | Engine outside React; summary, section, jobs, uploads/check endpoints |
| 55 to 60 | Uploads, caching, accessibility, and questions | Hash 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?
All layouts are for the old width, so they are invalid. Recompute the layout only for sections in view, fall back to count-based estimates for all others, recompute tops, and apply the anchor so the photo at the top of the screen stays there.
Thumbnails already loaded are close enough in size to reuse: rows get a bit shorter, so we keep the larger images instead of fetching new ones. Batch resize work into animation frames and run the full layout only once the size settles; the Google Photos post describes waiting about half a second.
Think first
Follow-up: a professional photographer has 2 million items. What breaks first?
The scroll height: about 83 million pixels, beyond what browsers allow for one element. Use a scaled scroll space: the spacer is capped (say 10 million pixels) and scroll positions map to content positions by a factor, with the scrubber hiding the difference; or switch to a paged timeline per year with the rail as the main navigation.
The summary grows to a thousand or more sections, still only a few hundred KB at most. Section items cache needs a tighter LRU. Selection ranges and bulk jobs are unchanged, which is the payoff of not using id lists.
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?
A section.changed event updates the count and version of today's section. It is above the viewport, so its new height goes through the anchor rule: scrollTop grows by the difference, and the user's view of 2015 does not move. If the section is not mounted, we only update the estimate; its items are fetched when the user goes there.
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.
References
- Building the Google Photos Web UI (Antin Harasymiv, 2018) Sections and segments, estimated heights corrected in one frame, the Knuth and Plass style layout and its timings, and low resolution placeholders, from the engineer who built the grid.
- flickr/justified-layout Flickr's open source greedy justified layout: a target row height with a tolerance, and a check of whether a row is better with or without its last photo.
- MDN: overflow-anchor Opting out of browser scroll anchoring.
- MDN: HTMLImageElement decoding The async decoding hint for images.
- MDN: createImageBitmap Decoding and resizing images, also inside workers.
- ThumbHash A compact image placeholder that also encodes the aspect ratio; compares itself with BlurHash.
- CSS Scroll Anchoring Module Level 1 How the browser picks an anchor node, and the suppression triggers (top, height, transform on the anchor's ancestors) that switch it off.
- ExifTool: EXIF tag names DateTimeOriginal and the OffsetTimeOriginal time zone tag, with their formats.
- WAI-ARIA APG: Grid pattern Keyboard and roles for two-dimensional widgets.
- Google Photos Library API: base URLs How size parameters on a base URL select the image size.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Uploading filesHow a file gets from the user's device to your storage: through your server or straight to object storage with a pre-signed URL, in resumable chunks with honest progress, checked on both sides, and served back without opening a security hole.