There is a particular kind of dread that comes with a launch event. Not the ordinary anxiety of shipping—the specific dread of knowing that at some announced minute, a marketing campaign will point several million people at a URL, and everything you built will either hold or it won't. There is no gradual ramp. There is no canary. There is a countdown, and then there is load.
We were building the public feed for a social platform: the surface anyone can see without logging in. Shared links, profile pages, discoverable content. It's the part of a social product that behaves least like a social product and most like a newspaper—the same content, served to everyone, over and over.
The brief had three constraints, and they pulled in different directions:
- Security of data. The public tier must never become a path to private data. Not through a bug, not through a misconfigured IAM binding, not through an SQL injection someone finds in month seven.
- Support ten million at the public-content tier. Not ten million rows in a table—ten million people asking for content.
- Users arriving as an outburst. A launch event, not a growth curve. The traffic shape is a spike, not a slope.
Constraint one says isolate. Constraint two says cache. Constraint three says make sure the cache is already warm when the wave lands, because a cold cache under a thundering herd is just a slow database with extra steps.
This is the story of the architecture that came out of those three sentences.
The Full Picture
Here's the whole design. It will look busy; that's fine. We'll take it apart piece by piece, and every piece is a complete subsystem you can reason about on its own.
The complete architecture. Two GCP projects, three domains, two content delivery networks, and one event-driven bridge between the private and public worlds.
The single most important thing on that diagram isn't a box. It's the gap between the two dashed rectangles.
Component 1: One App, Two Worlds
The trust boundary. One client, three domains, three completely separate paths into the system.
The mobile app is one binary, but it speaks to three different addresses, and that split is the foundation of everything else:
| Domain | Traffic | Authentication | Lands in |
|---|---|---|---|
api.** | Authenticated writes and reads | Required | Core project VPC |
api.public.** | Public reads | None | Public project VPC |
media.public.** | Public media fetches | None | Media CDN → GCS |
The obvious question: why not one API that checks whether the caller is authenticated and serves accordingly?
Because that design makes a single service responsible for both "serve this to everybody" and "serve this only to the right person," and those two responsibilities want opposite things. The public path wants to be cached aggressively and identically for every caller. The private path must never return an identical response to two different callers. Put them in one service and every cache decision becomes a security decision. You will get it right ninety-nine times, and the hundredth time you will serve someone else's drafts out of a CDN edge in Frankfurt.
So we split them at the DNS level and let the split propagate all the way down: separate GCP projects, separate VPCs, separate service accounts, separate Cloud Armor policies, separate load balancers, separate databases.
What this buys us, concretely:
- Blast radius. A remote code execution in the public application gives an attacker a shell in a project whose service account can read one database of already-public content. There are no credentials to the core project in that environment, because there is no reason for them to be there.
- Independent failure. The public tier can be melting under launch traffic while authenticated users continue to post, comment, and log in. They are not sharing a connection pool, a load balancer, or a CPU.
- Honest security review. When someone asks "can the public feed leak private data," the answer isn't a code walkthrough. It's an IAM policy you can print on one page.
The two projects communicate through exactly one channel, and it goes one way. We'll get to it.
Component 2: The Core Project — Where the Real Data Lives
The core project. Everything that requires identity happens here, and nothing here is reachable from the public tier.
This is the conventional part of the system, and that's deliberate. The core project is where the application actually lives: user accounts, private posts, drafts, direct messages, follower graphs, moderation state. Every request into it is authenticated, and every request is therefore personalised, and therefore essentially uncacheable.
Traffic enters through Cloud CDN, the Global Application Load Balancer, and Cloud Armor. On this path Cloud Armor is configured to be strict: tight per-IP rate limits, aggressive bot rules, and a low tolerance for anomalies. We can afford strictness here because a legitimate authenticated user makes tens of requests per minute, not thousands. Anyone hitting api.** at machine speed is doing something we don't want.
Behind the load balancer sits the backend service and the primary database—the system of record. This database holds everything, public and private alike, and it is the only place a write is ever accepted.
Notice the second path on the left: a private media CDN in front of a private GCS bucket. Private media—anything visible only to specific people—is served through signed URLs with short expiries. Signing is an access-control mechanism: the URL itself is the capability, it is issued per-viewer, and it stops working after a few minutes. That's exactly what you want when the object is private.
It is also exactly what you don't want when the object is public, and that distinction becomes the entire media strategy later on.
The important property of this whole subsystem is what's missing from it: it has no inbound path from the public project. Nothing in the public tier can call the backend, query the primary database, or read the private bucket. The arrow at the bottom of this panel points out.
Component 3: The Public Read Path — Three Layers of Absorption
The public read path. Every layer exists to prevent traffic from reaching the next one.
This is where the ten million goes, and it's built as a funnel. Each layer's job is to absorb an order of magnitude so the next layer never learns the surge happened.
Layer 1: Cloud CDN
The first and most important layer. Public feed responses are identical for every caller, which means one cached object at an edge location can serve an entire city.
The cache key is the thing that makes this work, and its defining property is what we left out of it. The key is built from three things: which surface is being requested (the landing feed, a public profile), which page of it, expressed as a cursor, and a schema version so a change to the response shape rolls the whole cache rather than serving two incompatible formats side by side.
What the key does not contain is any trace of identity. No user ID, no session, no Authorization header, no personalisation cookie. The first page of the public feed is one cached object, and that single object serves everyone who lands on it.
The moment you put a user identifier in that key, the cache fragments into ten million distinct objects, your hit ratio collapses, and the CDN becomes an expensive proxy. Keeping the public feed impersonal isn't a limitation we tolerated—it's the property that makes the whole design possible.
Two details matter more than they look:
Stale-while-revalidate. Responses carry something like Cache-Control: public, max-age=30, stale-while-revalidate=300. When the TTL expires, the edge keeps serving the slightly-stale object while a single background request refreshes it. Users never wait behind a revalidation. At launch scale, "never wait behind a revalidation" is the difference between a fast feed and a queue.
TTL jitter. If ten million people land in the same ninety seconds and every cached object gets the same thirty-second TTL, then thirty seconds later every object expires simultaneously and you've built a synchronised stampede generator. We jitter TTLs by a random ±20% so expiries spread out instead of aligning.
Cloud Armor sits at this entrance too, but configured differently from the core: generous rate limits (this tier is supposed to absorb enormous request volume), with the rules aimed at scrapers and volumetric abuse rather than at ordinary enthusiasm. Adaptive Protection watches for L7 attack patterns hiding inside the launch spike—because a launch event is the ideal cover for one.
Layer 2: Valkey
CDN misses reach the public application, which checks the Valkey cluster before it considers touching a database. Valkey holds the assembled feed payloads—already joined, already shaped as JSON, ready to serialise.
This layer catches two kinds of miss. The first is geographic: a request arriving at an edge that hasn't seen this object yet. The second, and more dangerous, is the moment a hot object expires everywhere at once.
That second case is the classic cache stampede, and it's the failure mode that actually kills systems on launch day. Ten thousand concurrent requests for the same expired key, all missing, all deciding to rebuild it, all hitting the database with the identical query.
We handle it by making the rebuild a privilege rather than a free-for-all. When a request misses, it first tries to claim a short-lived lock on that specific key—an atomic set-if-absent, with an expiry so a crashed worker can't wedge the key permanently. Exactly one request wins. That winner queries the database, writes the fresh payload back with a jittered TTL, and releases the lock. Everyone who lost the race waits a couple of hundred milliseconds for the winner's result, and if the rebuild takes longer than that, they're served the stale copy rather than being allowed to pile on.
Ten thousand concurrent misses become one database query, and nobody waits long enough to notice.
Layer 3: The Public Database
Only what survives both layers gets here—and this is a read model, not a copy of production. More on that in a moment, because it's the security keystone of the whole design.
One rule the public application follows without exception: cursor pagination, never offset. OFFSET 500000 makes the database walk half a million rows to throw them away, and it makes every page a distinct cache key with a distinct cost. A cursor is a stable pointer into an index. It's O(1)-ish regardless of depth, and it caches cleanly.
The Funnel, In Numbers
Here's why three layers rather than one. Say the launch drives 10 million requests in the opening window:
| Layer | Hit rate | Requests absorbed | Passed down |
|---|---|---|---|
| Cloud CDN | ~95% | 9,500,000 | 500,000 |
| Valkey | ~95% | 475,000 | 25,000 |
| Public database | — | 25,000 | — |
Ten million requests at the edge become roughly twenty-five thousand database queries. Spread across the launch window, that is an unremarkable afternoon for a modestly sized Postgres instance with a read replica.
The insight worth carrying out of this section: each layer only needs to be good, not perfect. Two layers at 95% is a 400× reduction. Three is 8,000×. You don't need a heroic cache hit ratio. You need multiplication.
Component 4: Media — Why Public URLs Are Deliberately Unsigned
The public media path. It doesn't touch the application tier at all.
Media is where a naive design quietly destroys its own scalability, so this deserves its own component.
Images and video are the overwhelming majority of bytes in a social feed. A feed page might be 40 KB of JSON and 4 MB of media. If media requests route through your application, your application is now a file server, and no amount of feed caching will save you.
So it doesn't. media.public.** resolves to a media CDN backed directly by a public GCS bucket. The public application is not in that path. It never sees a media request. The dashed VPC boundary in the panel above runs to the left of the media CDN for exactly that reason.
Now the part that reliably raises eyebrows in review: those URLs are unsigned.
The instinct to sign everything is a good instinct applied in the wrong place. Signed URLs are an access-control mechanism. They're per-viewer, they carry an expiry, and they only make sense when there's someone who should be denied. For genuinely public content there is no such person—the content is on a public profile, indexed by search engines, embeddable in a tweet. Signing it protects nothing.
And it costs enormously:
- A signed URL is unique per viewer, so it is a unique CDN cache key per viewer. Ten million viewers of the same image become ten million cache misses against your origin bucket. You've converted your CDN into a very expensive redirect service.
- Signatures expire, which breaks embeds, shared links, screenshots, and search-engine crawls—all the distribution mechanisms a public feed exists to enable.
- Generating them requires the application to be in the request path, which is precisely the thing this design removes.
So the rule is clean and follows from what the content actually is:
| Content | Bucket | Delivery | URL |
|---|---|---|---|
| Public media | Public GCS | media.public.** CDN | Unsigned, permanent, infinitely cacheable |
| Private media | Private GCS | Private media CDN (core project) | Signed, short expiry, per-viewer |
Two buckets, two CDNs, two policies. The security control isn't the signature—it's which bucket the object was written to, decided once at upload time by a service that knows who the author is and what they chose. That decision is made in the core project, under authentication, and it is never re-evaluated at read time. Access control that happens once at write time cannot be bypassed by a bug at read time.
Component 5: The Write Path — Ordering Failures So They're Survivable
The write path. Two labelled rules, each encoding a hard-won lesson about what to do when a step fails.
Reads are a funnel. Writes are a sequence—and the order is chosen so that partial failure leaves something harmless.
Rule 1: Media to GCS first, then the post to the database
The label on the diagram is deliberately blunt, because the reverse order is such a tempting mistake. Write the row first and you get a fast API response and a nice optimistic UI. You also get this: the media upload fails, and now a post exists in the database pointing at an object that isn't there. Every viewer of that post sees a broken image. The feed is corrupted from the reader's point of view, and no retry fixes it because the client that held the bytes is long gone.
Flip the order and the worst case is an object sitting in a bucket that no row references. Nobody sees it. A sweeper job deletes unreferenced objects older than a day. Storage is cheap; broken references are user-visible.
Order your writes so that failure leaves garbage, not holes. Garbage is a background job. Holes are a support ticket.
Rule 2: Event-driven write to the public database
This is the one channel between the two worlds, and it is strictly one-directional: the core project publishes, the public project consumes. There is no path back.
We use the transactional outbox pattern, and the reason comes down to a question that sounds pedantic until it bites you: when the backend saves a post and then announces it, what happens if it crashes between the two?
The naive version is "commit the post, then publish the event." Those are two separate systems, and there is a window between them. A crash inside that window leaves a post that exists in the primary database but was never announced. It will never appear on the public feed, no retry will fix it, and nothing will alert you—because from the database's point of view the write succeeded perfectly. You find out when a user asks why their post is invisible, days later.
The outbox closes the window by refusing to treat the announcement as a separate step. When the backend commits a post, it writes two rows inside one transaction: the post itself, and an outbox row describing the event. Either both land or neither does. There is no in-between state, because the database's own atomicity guarantee is doing the work. The event becomes exactly as durable as the data it describes.
From there, a relay process reads unpublished outbox rows and pushes them onto Pub/Sub, marking them dispatched as it goes. A consumer in the public project picks them up and projects them into the public database. Because Pub/Sub delivers at-least-once, that consumer has to be idempotent—it upserts on the post's ID, so a redelivered event overwrites a row with identical content and changes nothing. Duplicate delivery becomes a non-event rather than a duplicated post.
The trade is that the relay adds latency, typically well under a second. That's the eventual-consistency window, and we'll come back to what it costs.
The Projection Is the Security Boundary
Here is the part I'd most want a reviewer to look at closely.
The consumer doesn't replicate the posts table. It projects a deliberately narrow read model, and the interesting part is the subtraction:
| The public table holds | It deliberately omits |
|---|---|
| Post ID, body, and creation time | Email addresses and phone numbers |
| The author's public handle and display name | The internal user ID |
| The public bucket path for any media | The follower graph and any social edges |
| Moderation state, reports, and review flags | |
| Drafts and anything not yet published | |
| A visibility column — see below |
Two decisions in there are worth pulling out.
The author is denormalised into the row. The public feed carries the handle and display name directly rather than a foreign key. That's a performance choice—posts are served without joining anything—but it's also a security one. There is no users table in the public project to join to, so there is no query, however malformed, that reaches user records from a post.
There is no visibility column, and its absence is the point. A visibility flag implies rows of both kinds are present and something is filtering them at read time. Here the filter runs at projection time: the consumer drops anything that isn't public before it ever writes. Private posts don't arrive marked private. They don't arrive.
This is the property that makes the security argument simple. It isn't "the public API carefully filters private data." It's:
The public database cannot leak private data, because private data was never written to it.
An SQL injection in the public tier returns public posts. A leaked read credential returns public posts. A logic bug that forgets a WHERE clause returns public posts. The worst outcome of a total compromise of the public tier is that someone obtains, at speed, the content we were already publishing to anyone who asked.
The cost is eventual consistency: a second or two between publishing and appearing on the public feed. Authenticated users read their own posts through api.** against the primary database, so authors get read-your-writes immediately and never see the lag. Everyone else is reading a feed of other people's posts, where a two-second delay is imperceptible. That's a very cheap price for a boundary this strong.
Launch Day: Following One Request Through
Constraint three was the outburst. Let's trace what actually happens at T-minus-zero.
Before the wave. The cache is warmed, not cold. In the hour before launch we run a warmer that requests every landing surface—the first page of the public feed, the campaign's linked profiles, the first cursor page of each—through the public path, so the CDN and Valkey both hold hot copies at every edge. A launch that begins with an empty cache is a launch that begins with ten million cache misses.
The wave. The first person taps the link. Cloud Armor evaluates their request, the edge holds a warm object, and it's served without any part of our infrastructure being consulted. So are the next several hundred thousand, at edges around the world.
The stragglers. A miss slips through at some edge—new region, expired object. It reaches the public application, which finds the payload in Valkey and returns it in single-digit milliseconds. Still no database.
The rare full miss. A handful reach the public database. It's a narrow, denormalised table with a cursor index, answering a query that touches a few dozen rows. It's fine. If it isn't, the fix is horizontal: this database is a read model with a single-writer feed of events, so read replicas are trivial to add and impossible to make inconsistent with a primary that doesn't exist here.
Media, in parallel. Every image is a separate unsigned, permanently-cacheable request against a CDN that has never heard of our application tier. Media scaling is entirely decoupled from feed scaling.
Meanwhile, in the core project. Authenticated users are signing up, posting, and reacting—on different infrastructure, in a different project, behind a different load balancer, against a different database. The launch surge is not their problem. Each new public post flows across the outbox to the public tier, where it invalidates the relevant cache keys and gets re-fetched once on the next miss.
What We Traded Away
No design is free, and a post that doesn't say what it gave up isn't describing a design—it's advertising one.
Personalisation on the public feed is off the table. Anything user-specific fragments the cache key and collapses the hit ratio. The public feed is a newspaper: everyone gets the same edition. Personalisation lives behind api.**, for people who are logged in, where responses aren't cached anyway.
Two schemas to evolve. The projection is a contract between the projects. Adding a field to the public feed means a schema change, a consumer change, and a backfill. We version the event payload for this reason, and it is real ongoing cost.
More infrastructure. Two projects, two load balancers, two CDNs plus two more for media, a message bus and a consumer. That is genuinely more to operate than one monolith with a cache in front. We accepted it because the alternative—one tier serving both audiences—concentrates every risk in the component under the most load.
Eventual consistency is now a product surface. Most of the time nobody notices. But "I deleted my post and a friend could still see it for three seconds" is a real conversation you will eventually have. Deletions get an expedited path and an explicit cache purge for precisely this reason; it's the one place where the lag is not acceptable.
What I'd Carry Into the Next Design
Isolation is cheaper before you need it than after. Splitting the public tier into its own project cost us a week at design time. Retrofitting that boundary into a live system, after the public and private paths have grown shared libraries, shared connection pools, and shared assumptions, costs a quarter and a rewrite.
Make privacy a property of the data's location, not of the code's behaviour. Filtering at read time means every future query is a chance to get it wrong. Filtering at write time means the wrong data isn't there to return. One is a discipline you have to maintain forever; the other is a fact about the system.
Layers multiply. Two 95% caches are a 400× reduction. Chasing one layer from 95% to 99% is much harder, and buys less, than adding a second layer that's merely good.
Signing public content is a category error. Authentication mechanisms applied to unauthenticated content protect nothing and destroy cacheability. Ask what the control is actually for before applying it.
Order your writes for the failure you can live with. Media before metadata. Outbox inside the transaction. Every write sequence should be arranged so that a crash halfway through leaves something a background job can clean up, rather than something a user can see.
Warm the cache before the event. All the architecture above is worth nothing in the first ninety seconds if the edges are empty. The most sophisticated funnel in the world still needs someone to fill it in advance.
The design isn't clever, and I mean that as praise. It's a boundary drawn in the right place, three caches stacked in a row, and a one-way bridge between them. Most of the work was in deciding where the boundary went—and once it went between "content anyone may see" and "content only some may see," almost everything else followed from that single line.