lumo

Module 20 · Lesson 2 of 10Free

Design: a photo-sharing app like Instagram

Level
Intermediate
Topic
End-to-end design problems
Reading time
30 min

When you finish this page you can

  1. Design a photo’s lifecycle from an interrupted upload to a published feed item.
  2. Separate image delivery from metadata storage and feed generation.
  3. Choose feed generation strategies from traffic and follower distributions.
  4. Handle retries, deletion and worker failures without exposing unfinished photos.
  1. Direct uploads to object storage18 min
  2. Image processing: resizing and CDN delivery19 min
  3. Feed generation: fan-out on write vs on read20 min
  4. Idempotency: making retries safe18 min

Video lesson · coming soon

Asha uploads a 2 MB outfit photo to . Her phone loses signal after the upload finishes. She retries, and her followers see two posts—one with a broken image.

How can one photo become one usable post, even when uploads, processing and feed delivery fail independently?

We’ll design Kartly’s new photo-sharing feature, not describe Instagram’s internal architecture. Kartly is already at its many-services stage. We’ll start with a small implementation inside that system, then add complexity only where this feature needs it.

Check yourselfAnswer in your head first, then reveal
The object store confirms that Asha’s upload succeeded. Is it safe to insert the photo into her followers’ feeds?Show answerHide answer
Not yet. Stored bytes might not decode as an image, and the feed-sized version might not exist. Upload completion proves storage, not readiness.

Separate the bytes from publication

The first boundary separates a file from a post. A file is stored bytes. A post is a database record that says who owns those bytes and whether readers may see them.

Version 1 uses direct uploads, covered in Direct uploads to object storage. The API handles small control requests. handles large transfers.

Diagram not drawn yet: kartly-photos-v1.svg

A phone creates an upload through the Photo API and uploads bytes directly to object storage. The API checks and resizes the upload synchronously, then publishes metadata in PostgreSQL. Readers request feed metadata from the API and image bytes through the CDN.

Version 1 separates image bytes from post metadata. Processing still happens during the completion request.

A is a network of nearby servers that image responses. We choose it because many followers request identical bytes. We keep originals and generated images in private object storage; only approved delivery paths can read them.

For now, the API resizes the image during the completion request. A successful response means the post is published. This works for a controlled rollout, but ties the phone’s request to image-processing time.

Product answers set the boundaries

Product question Answer for this design Effect on the system
What can people publish? One photo with a caption No album ordering or partial album publication
What appears in the home feed? Recent posts from followed accounts Chronological ordering, not recommendation ranking
Who can see a post? Public accounts only at launch No follower-approval workflow
What interactions exist? Follow, like, comment and delete Separate records, not arrays inside posts
How fresh is the feed? Aim for 2 seconds under normal load Background distribution needs a lag target
Must a new post appear immediately for its author? Yes, after publication Read the author’s published posts directly
What must survive retries? One post per publishing intention Persist request identity before transferring bytes

These are design assumptions, not existing Kartly measurements. Existing customers, catalog traffic and orders remain unchanged.

We exclude video, stories, direct messages and automated recommendation ranking. Caption editing is also outside this first version. Those features introduce different processing or ordering rules.

Publication correctness is stricter than feed freshness. A delayed photo is acceptable during an incident. A feed item pointing to unfinished bytes is not.

Size reads and writes separately

For this lesson’s adoption scenario, assume Kartly’s 5 million daily customers use the photo feature. Assume 1 million photos arrive daily. Each customer opens ten feed pages.

The following estimates use decimal storage units. They exclude replication, backups and protocol overhead.

Photo arrival rate

1,000,000 photos/day ÷ 86,400 s/day ≈ 12 photos/s

A peak ten times the average is about 120 photos per second. This is a planning assumption, not a measured peak.

Daily image storage

1,000,000 × (2 MB original + 0.6 MB variants) = 2.6 TB/day

Keeping every original costs much more than storing post metadata. Retention needs its own budget.

Feed page requests

5,000,000 customers × 10 pages/day = 50,000,000 pages/day

That is about 580 page requests per second on average. Our assumed peak is about 5,800.

Image delivery volume

50,000,000 pages × 20 images/page × 0.2 MB/image = 200 TB/day

This assumes every image on every page is fetched. Lazy loading can reduce it. The CDN handles these bytes, not the Photo API.

The estimates justify separate scaling paths. More API instances do not reduce image-transfer costs. More processing workers do not make an expensive feed query cheaper.

Make one upload one intention

The Photo API owns the publishing contract. The client chooses a before its first request and stores it locally until the operation finishes.

This builds on Idempotency: making retries safe. The key identifies an intention, not a file’s contents. Posting the same photo tomorrow is a different intention.

The API owns object names

http
POST /v1/photo-posts
Authorization: Bearer customer-token
Idempotency-Key: publish-asha-017
Content-Type: application/json

{
  "caption": "My festival outfit",
  "upload": {
    "bytes": 2000000,
    "content_type": "image/jpeg",
    "sha256_base64": "base64-encoded-sha256"
  }
}
http
HTTP/1.1 201 Created
Location: /v1/photo-posts/p_17
Content-Type: application/json

{
  "id": "p_17",
  "state": "UPLOADING",
  "upload": {
    "method": "PUT",
    "url": "https://uploads.kartly.example/signed-upload",
    "required_headers": {
      "Content-Type": "image/jpeg",
      "x-amz-checksum-sha256": "base64-encoded-sha256"
    },
    "expires_in_seconds": 600
  }
}

The API chooses a unique staging key. It never accepts a client-selected storage path.

The unique is (author_id, request_key). A retry returns the same post ID. Changed caption or upload metadata with the same key returns 409 Conflict. Store those original request fields for comparison.

An S3 presigned URL authorizes a particular operation against an object. It can be reused before expiry, and another upload to the same key replaces the object. It is not a single-use ticket. 1

Our design signs the checksum header and requires the original checksum on every retry. S3 supports checksum validation with Signature Version 4 presigned uploads. 1

That prevents later retries from substituting different bytes under the same upload contract. It does not prove the file is safe. The server still needs to decode it.

Completion is a separate request

After the PUT succeeds, the client sends:

http
POST /v1/photo-posts/p_17/complete
Authorization: Bearer customer-token
Content-Type: application/json

{}

Version 1 checks ownership, reads the stored object, validates it and generates feed-sized variants. It then publishes the post and returns its representation.

Endpoint Contract
GET /v1/photo-posts/:id 200 with current state; 404 if unavailable to this viewer
POST /v1/photo-posts/:id/complete Repeatable; does not create another post
GET /v1/feed?cursor=…&limit=20 200 with items and an opaque next cursor
PUT /v1/follows/:author_id 204; repeating it keeps one relationship
DELETE /v1/follows/:author_id 204; repeating it remains unfollowed
PUT /v1/photo-posts/:id/like 204; one like per viewer
DELETE /v1/photo-posts/:id/like 204; safe to repeat
POST /v1/photo-posts/:id/comments 201; requires a client-generated comment ID
DELETE /v1/photo-posts/:id 204 for an owner’s accepted deletion; repeatable

Malformed requests return 400. Oversized upload declarations return 413. Missing authentication returns 401. Requests against another author’s writable resource return 404 to avoid confirming its existence.

The upload contract solves duplicate identity. It does not solve a slow completion request.

Move processing behind a durable job

The upload worked, but the phone timed out

  1. Asha uploads her photo successfully.
  2. Completion starts decoding and resizing it.
  3. Other uploads consume the API’s processing capacity.
  4. Her request times out before publication.
  5. The app cannot tell whether to wait or retry.

Adding an asynchronous function inside the API process is not enough. A restart would discard the unfinished work.

Where should image processing run?

Keep processing inside the completion request.

The implementation is short, but expensive images occupy request capacity. A disconnected phone gets no useful progress result.

Trust client-generated images as the published assets.

The phone can resize quickly, but an untrusted client can omit checks or upload malformed results. Different clients may produce inconsistent variants.

Why this choice

Image work has a different resource profile from metadata requests. Separate workers let us limit decoder memory and CPU without slowing feed reads.

Client resizing remains useful for reducing upload bytes. It does not replace server validation. Synchronous processing remains reasonable for a small, tightly controlled tool with predictable inputs.

Version 2 changes completion to return 202 Accepted with state: PROCESSING. The phone uses against the post resource until publication or failure.

Diagram not drawn yet: kartly-photos-v2.svg

The phone uploads directly to object storage. The Photo API atomically records PROCESSING and a job in PostgreSQL. A separate processor leases the job, reads the original and writes variants. It commits the published manifest before readers receive image URLs through the CDN.

Version 2 adds durable processing jobs and isolated workers. Publication waits for completed variants, not an open phone connection.
Component Responsibility Talks to State kept Failure behavior
Photo API Ownership, contracts and publication reads SQL, storage signer No durable local state Client retries through another instance
PostgreSQL Posts and durable work API, processors Metadata and jobs New publication pauses
Processor Validate, resize and finalize SQL, storage Temporary working files Lease expires; another worker retries
Object storage Originals and variants Phone, processor, CDN Image bytes Processing waits; uncached reads may fail
CDN Deliver completed variants Phone, storage Cached responses Existing hits may work; misses need storage

Publication is an explicit transition

The post follows a . PROCESSING is not a weaker form of published. Feed queries exclude it entirely.

Current state Event Next state
UPLOADING Owner completes an existing upload PROCESSING
UPLOADING Upload deadline passes FAILED
PROCESSING Valid variants are stored PUBLISHED
PROCESSING Invalid image or retry budget exhausted FAILED
Any non-deleted state Owner deletes DELETED
DELETED Late completion or worker result DELETED

In one database transaction, completion changes UPLOADING to PROCESSING and inserts the processing job. If either operation fails, neither commits.

Workers claim jobs with a short transaction. They record a lease deadline and increment a generation number. They release the database transaction before reading or decoding image bytes.

PostgreSQL’s SELECT FOR UPDATE locks the selected rows against competing writers until transaction end. We use that behavior only for short ownership and finalization transactions. 2

Keep the authoritative records compact

The Photo service owns these tables. Existing account records supply author IDs; cross-service account validation is outside this schema.

sql
CREATE TABLE photo_posts (
  id TEXT PRIMARY KEY,
  author_id TEXT NOT NULL,
  request_key TEXT NOT NULL,
  caption TEXT NOT NULL,
  input_key TEXT NOT NULL UNIQUE,
  input_bytes BIGINT NOT NULL CHECK (input_bytes > 0),
  input_sha256 TEXT NOT NULL,
  input_type TEXT NOT NULL,
  state TEXT NOT NULL CHECK (
    state IN ('UPLOADING','PROCESSING','PUBLISHED','FAILED','DELETED')
  ),
  manifest JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ,
  UNIQUE (author_id, request_key),
  CHECK (state <> 'PUBLISHED' OR
         (manifest IS NOT NULL AND published_at IS NOT NULL))
);

CREATE INDEX photo_posts_author_recent
ON photo_posts (author_id, published_at DESC, id DESC)
WHERE state = 'PUBLISHED';

CREATE TABLE photo_jobs (
  post_id TEXT NOT NULL REFERENCES photo_posts(id),
  kind TEXT NOT NULL CHECK (kind IN ('PROCESS','FANOUT','DELETE')),
  generation BIGINT NOT NULL DEFAULT 0,
  leased_until TIMESTAMPTZ,
  attempts INT NOT NULL DEFAULT 0,
  available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  progress JSONB NOT NULL DEFAULT '{}',
  done BOOLEAN NOT NULL DEFAULT false,
  PRIMARY KEY (post_id, kind)
);

CREATE INDEX photo_jobs_due
ON photo_jobs (available_at) WHERE NOT done;

The author index serves profile pages and feed retrieval by author. The job index finds due work; claim logic also checks lease expiry. We start with a database-backed work , rather than operate another broker immediately.

Validate bytes before trusting metadata

The processor decodes the stored bytes in a resource-limited environment. It ignores the declared image type when choosing a decoder. It rejects excessive dimensions, strips location metadata and generates fixed-size variants.

Here is the small policy function both implementations use after decoding. The decoder supplies these facts; request fields do not.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class DecodedImage:
    width: int
    height: int
    frames: int

def variant_widths(image: DecodedImage) -> tuple[int, ...]:
    if image.width <= 0 or image.height <= 0:
        raise ValueError("invalid dimensions")
    if image.width * image.height > 40_000_000:
        raise ValueError("pixel limit exceeded")
    if image.frames != 1:
        raise ValueError("only still images are supported")
    return tuple(sorted({min(image.width, n) for n in (320, 640, 1080)}))
ts
type DecodedImage = Readonly<{
  width: number;
  height: number;
  frames: number;
}>;

function variantWidths(image: DecodedImage): number[] {
  const { width, height, frames } = image;
  if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) ||
      width <= 0 || height <= 0) {
    throw new Error("invalid dimensions");
  }
  if (width * height > 40_000_000) {
    throw new Error("pixel limit exceeded");
  }
  if (frames !== 1) {
    throw new Error("only still images are supported");
  }
  return [...new Set([320, 640, 1080].map(n => Math.min(width, n)))]
    .sort((a, b) => a - b);
}

The pixel limit is this lesson’s illustrative policy. A small compressed file can still require substantial decoding memory. The policy complements decoder resource limits; it cannot replace them.

Each processing attempt writes variants under new, attempt-specific keys. Its records keys, dimensions, types and byte sizes. Retries never overwrite a variant already selected for publication.

Finalization decides which attempt wins

After writing variants, the processor begins a short transaction:

  1. Lock its job row and verify the recorded generation still matches.
  2. Lock the post row.
  3. If the post is still PROCESSING, save the manifest and change it to PUBLISHED.
  4. Insert a FANOUT job in the same transaction.
  5. Mark the processing job done and commit.

All code touching both rows locks the job first, then the post. Consistent lock order reduces deadlock risk. PostgreSQL documents deadlock detection and recommends consistent acquisition order. 2

A worker whose lease expired might still finish. A newer generation prevents that stale worker from selecting its manifest. A deleted post also fails the state check. Its unused variant files become cleanup work.

The reader now has a complete image, but finding the right readers is a separate problem.

Start the feed with indexed reads

Version 2 still builds feeds on demand. The Feed API fetches the viewer’s follow list, retrieves recent published posts and orders them newest first.

Use publication time, not upload creation time. A photo that takes longer to process should enter the feed when it becomes readable.

The Graph service owns follows:

sql
CREATE TABLE follows (
  follower_id TEXT NOT NULL,
  author_id TEXT NOT NULL,
  PRIMARY KEY (follower_id, author_id),
  CHECK (follower_id <> author_id)
);
CREATE INDEX follows_by_author ON follows (author_id, follower_id);

The serves “who do I follow?” The reverse index serves distribution to an author’s followers.

For a small rollout, the Feed API can merge indexed author queries. Later, batching and shard-local queries reduce network calls. Do not issue one sequential service request per followed author.

The Interaction service stores likes with primary key (post_id, viewer_id). Comments use a client-generated ID unique per author. It also indexes (post_id, created_at, id) for comment pages.

These records remain separate from feed entries. Liking a photo must not rewrite every follower’s feed. Like counts may lag; the viewer’s own like state comes from the unique relationship.

A cursor fixes the ordering boundary

A feed response contains post IDs, captions, variant metadata and a next cursor. It does not embed image bytes.

json
{
  "items": [
    {
      "id": "p_17",
      "author_id": "asha",
      "caption": "My festival outfit",
      "published_at": "2026-10-25T10:00:00Z",
      "image": {
        "path": "/photos/p_17/attempt-4/640.webp",
        "width": 640,
        "height": 800
      }
    }
  ],
  "next_cursor": "opaque-signed-cursor"
}

The cursor carries a fixed upper boundary and the last scanned (published_at, id) pair. The ID breaks timestamp ties. This follows Pagination: offset vs cursor-based.

Newly published posts above that boundary wait for refresh. Delayed distribution can still cause an older item to be missed during one scrolling session. We promise stable ordering, not a frozen snapshot of all future arrivals.

Read-time assembly is easy to repair because posts remain the source. At full adoption, repeated assembly becomes expensive.

Every reader repeats the same merge

A viewer follows 200 authors in our example. Suppose feed assembly considers 20 recent candidates from each author.

That creates up to 4,000 candidates for one page. At the assumed peak, that is about 23 million candidate records per second before filtering.

This is a workload estimate, not a database benchmark. Some queries will avoid reading every candidate. The estimate shows why , cache behavior and follower distribution need measurement.

Customers pay through slow first pages, even though every individual author query looks small.

Should we assemble feeds when authors post or when followers read?

: assemble every page from followed authors.

Publishing stays cheap, and inactive followers cost nothing. Active readers repeatedly retrieve and merge author histories.

: precompute every follower’s feed.

Reading becomes a short range lookup. Each publication creates one entry per follower, including people who never open the app.

Why this choice

With 200 recipients per post, our daily volume creates about 200 million feed entries. That averages about 2,300 inserts per second. The assumed posting peak produces about 24,000 inserts per second.

Now assume an isolated worker pool sustains 10,000 inserts per second. One author with 2 million followers occupies that capacity for about 200 seconds. This misses the freshness target before any other author posts.

Use read-time retrieval for that author. Keep ordinary publications on the write path. The threshold depends on spare worker capacity and active readership, not a universal follower count.

A read-heavy product with modest follow counts favors precomputation. A product with mostly inactive followers may remain cheaper on read.

The underlying alternatives are developed in Feed generation: fan-out on write vs on read. Here, the important boundary is that feed entries are disposable references, not copies of authoritative post state.

Diagram not drawn yet: kartly-photos-v3.svg

A phone reads through the Feed API. The API merges a per-viewer feed inbox with recent posts from large followed authors, then checks authoritative post state. A distribution worker reads durable fan-out jobs and writes inbox entries. The media pipeline supplies published posts, while image bytes travel separately through the CDN.

Version 3 adds a feed inbox and distribution workers. Large authors remain on the read path; image delivery stays separate.

Distribution must survive partial progress

The Feed service owns this derived table:

sql
CREATE TABLE feed_entries (
  viewer_id TEXT NOT NULL,
  post_id TEXT NOT NULL,
  author_id TEXT NOT NULL,
  published_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (viewer_id, post_id)
);
CREATE INDEX feed_entries_page
ON feed_entries (viewer_id, published_at DESC, post_id DESC);

The distribution worker reads followers in ID order. It writes a bounded batch with duplicate inserts ignored. Only after the destination acknowledges the batch does it advance the job’s progress cursor.

A crash before cursor advancement repeats a batch. The primary key absorbs duplicates. Advancing first would permanently skip followers after a crash.

This is at-least-once distribution with duplicate-safe inserts, not exactly-once execution. The durable publication job applies the Transactional outbox principle without requiring a separate broker.

New follows read recent author posts while background backfill catches up. Unfollows take effect during final filtering, even if old inbox rows remain.

Merge references, then check permission

The Feed API merges inbox candidates with large-author candidates. It deduplicates IDs, orders them and checks current eligibility.

This small function shows the merge after batched authoritative checks. Its input rank is an integer publication timestamp. Production cursors also carry the fixed upper boundary.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Candidate:
    id: str
    published_us: int

def feed_page(
    inbox: list[Candidate],
    pulled: list[Candidate],
    eligible_ids: set[str],
    before: tuple[int, str],
    limit: int = 20,
) -> list[Candidate]:
    if not 1 <= limit <= 100:
        raise ValueError("invalid limit")
    unique = {p.id: p for p in inbox + pulled}
    ordered = sorted(
        unique.values(),
        key=lambda p: (p.published_us, p.id),
        reverse=True,
    )
    return [
        p for p in ordered
        if (p.published_us, p.id) < before and p.id in eligible_ids
    ][:limit]
ts
type Candidate = Readonly<{ id: string; publishedUs: number }>;
type Rank = readonly [number, string];

function feedPage(
  inbox: readonly Candidate[],
  pulled: readonly Candidate[],
  eligibleIds: ReadonlySet<string>,
  before: Rank,
  limit = 20,
): Candidate[] {
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
    throw new Error("invalid limit");
  }
  const unique = new Map<string, Candidate>();
  for (const p of [...inbox, ...pulled]) unique.set(p.id, p);
  const ordered = [...unique.values()].sort((a, b) =>
    b.publishedUs - a.publishedUs ||
    (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
  return ordered.filter(p =>
    (p.publishedUs < before[0] ||
      (p.publishedUs === before[0] && p.id < before[1])) &&
    eligibleIds.has(p.id)
  ).slice(0, limit);
}

IDs use a restricted ASCII alphabet, and timestamp integers must fit TypeScript’s safe integer range. Both implementations therefore use the same ordering.

The function sorts a bounded candidate batch. If filtering leaves too few items, the API scans another batch up to a work limit. The cursor advances past the last scanned candidate, not merely the last visible one.

Large follow lists still make the pull side expensive. Cap supported follows for this version and measure that path before expanding it.

Deletion crosses both delivery paths

A can contain yesterday’s permissions. It must never become the authorization database.

OWASP recommends permission checks on every request, including access to static resources. Checking the feed endpoint alone does not protect image URLs. 3

For launch, accounts are public. Deletion still needs a precise contract:

  • The owner’s delete transaction sets DELETED and inserts durable cleanup work.
  • Feed and profile reads exclude deleted posts using current authoritative state.
  • Media delivery checks current post state before serving bytes, including .
  • Cleanup removes feed references, purges cached variants and deletes stored objects.

The media check runs in a controlled delivery layer before the CDN serves a response. Storage origins reject direct public access. A random-looking object path is not permission.

Can deletion wait for image URLs to expire?

Use short-lived bearer URLs and accept a revocation window.

Delivery is simple, but an already issued URL may keep serving a deleted photo until expiry. Purging alone may also take time.

Why this choice

We choose the second approach because deletion is a product promise, not only storage maintenance. If the authoritative check fails, media access fails closed.

For a public marketing gallery with an accepted removal delay, short-lived URLs could be cheaper. Private accounts would strengthen the need for viewer-specific checks.

No approach can erase bytes already downloaded or stop a request whose authorization completed before deletion.

S3 presigned URLs are : anyone possessing one can use its granted access. Keep upload URLs out of logs and never reuse them as permanent viewing URLs. 1

The correctness path is now complete. Production operations must keep its separate stages visible.

Operate stages, not one success rate

The final system has three independently scaled paths: publication, feed assembly and image delivery.

Component group Responsibility Talks to State kept Failure response
Photo API and processors Turn uploads into published posts Post SQL, jobs, storage Posts, manifests, leases Show processing; retry durable work
Graph service Follow relationships Feed API, distributors Follow edges Fail feed eligibility checks closed
Distribution workers Populate ordinary-author inboxes Jobs, graph, feed store Progress cursors Retry batches without duplicate entries
Feed API and store Merge and paginate references Graph, post metadata Derived inbox rows Bound fallback reads
Media delivery layer Authorize and deliver bytes Post state, CDN, storage Cached image bytes Deny on uncertain permission
Interaction service Likes and comments Post checks, interaction SQL Unique relationships and comments Retry with stable identities

Trace the write and read paths

Publication:

  1. The API creates one upload record.
  2. The phone transfers bytes directly to storage.
  3. Completion atomically records processing work.
  4. A processor stores validated variants.
  5. Finalization publishes the manifest and records distribution work.
  6. Distribution inserts duplicate-safe feed references.

Reading:

  1. The Feed API retrieves inbox and large-author candidates.
  2. It checks follows and current post eligibility in batches.
  3. It returns ordered metadata and a cursor.
  4. The phone requests an appropriately sized variant.
  5. The delivery layer authorizes the request before the CDN serves it.

The phone loads images near the viewport instead of downloading a whole feed in advance. Variant dimensions also let it reserve space before bytes arrive.

Detect the failure users experience

Failure What the person sees Detection Recovery
Phone loses upload response Upload appears uncertain Client resumes the same post ID Retry the signed operation or renew its URL
Processor crashes Post stays processing Oldest processing age Lease expiry and retry
Invalid image Upload cannot publish Decoder rejection reason Mark failed; ask for another file
Fan-out falls behind Followers see the post late Oldest distribution-job age Add capacity or reduce write-path work
Feed store unavailable Home page fails or degrades Read errors and saturation Bounded author-query fallback
Storage unavailable Uncached images fail Origin error rate Preserve metadata; retry with backoff
Cleanup fails Deleted bytes remain stored Cleanup-job age Retry; delivery checks still deny access

Never convert an unavailable feed store into unlimited database scans. That can turn a into a wider outage. A bounded fallback may return fewer items.

Grow from measured constraints

At ten times this scenario, increase processor capacity only if CPU or processing age requires it. Partition feed entries by viewer because each page belongs to one viewer.

At one hundred times, a single post database may no longer meet write and maintenance targets. Partition posts by author and keep each post’s jobs beside its authoritative row. This preserves local publication transactions.

Changing an author between pull and push modes needs overlap. Backfill the destination mode, read both temporarily and deduplicate before retiring the old mode. An abrupt switch could hide recent posts.

Retention and repair beyond the main request paths

Keep staging uploads on a short retention schedule. Before removing expired uploads, check whether their posts are still eligible for completion.

Attempt-specific variants can become orphaned when workers lose their generation race. A delayed sweeper removes unreferenced attempts after a safety interval.

Feed entries can have a bounded retention window because author histories remain authoritative. Rebuilding them costs work, but must not change post identity.

The illustrative image policy needs a maintained decoder, execution limits and an abuse-review process. This lesson’s dimension check is only one part of that boundary.

Publishing photos makes duplicate display inconvenient; moving money makes duplicate execution much more costly, which is the next lesson’s problem.

Your turn

Check yourselfAnswer in your head first, then reveal
A seller gains 2 million followers overnight. Its author mode changes from precomputed feeds to read-time retrieval. How would you avoid making yesterday’s photos disappear?Show answerHide answer
Read both sources during the transition and deduplicate by post ID. Keep the old entries until the pull path covers the required history; changing a mode flag alone does not migrate existing visibility.

Common mistakes

  • Publishing when the upload finishes. Stored bytes still need validation and usable variants.
  • Using feed entries as post records. Deletion and permission changes would require perfectly updating every copy.
  • Advancing distribution progress before inserts succeed. A crash would skip followers permanently.
  • Treating a lease as proof the old worker stopped. A generation check must reject stale finalization.
  • Choosing a follower threshold without a capacity budget. Distribution time depends on posting rate, recipient count and available worker capacity.

Check your understanding

Check yourselfAnswer in your head first, then reveal
A processing worker writes all variants, then crashes before saving the manifest. Should followers see the post?Show answerHide answer
No. Only the committed PUBLISHED state makes the post eligible. A retry can finish publication; unreferenced files are cleanup work, not evidence of publication.
Check yourselfAnswer in your head first, then reveal
A distribution worker inserts a batch but crashes before saving its progress cursor. Will retrying create duplicate feed items?Show answerHide answer
Not with the unique viewer-and-post key. The batch repeats, but duplicate-safe inserts preserve one reference per viewer.
Check yourselfAnswer in your head first, then reveal
A deleted post remains in a follower’s inbox. Must the delete request wait for every inbox row to be removed?Show answerHide answer
No. Current post checks reject it during reads and media delivery. Inbox cleanup can happen later because the inbox is not authoritative.
Check yourselfAnswer in your head first, then reveal
A customer rarely opens the app but follows many active authors. Which feed strategy avoids writes for pages they never read?Show answerHide answer
Fan-out on read. Precomputing every publication spends work on inactive recipients, so low readership can change the hybrid decision.
Check yourselfAnswer in your head first, then reveal
Feed metadata is fast, but images take several seconds to appear. Would adding distribution workers fix the problem?Show answerHide answer
Not usually. Distribution controls which references exist; image delay belongs to delivery, variant size or the phone’s network. Measure that path separately.

Summary

  1. One upload becomes one usable post through stable request identity, durable processing and an explicit publication commit.
  2. Store image bytes separately from metadata; deliver completed variants through an authorized CDN path.
  3. Finalize workers with generation checks, and distribute feed references with duplicate-safe batches.
  4. Choose read, write or hybrid feeds from recipient costs and actual readership—not total users alone.
  5. Keep post state authoritative so deletion works before every cached reference or stored file is cleaned up.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.