Design a Messaging & Notification System
- Split delivery into an accept path that returns in milliseconds and a deliver path behind a queue
- Guarantee at-least-once delivery, deduplicated by a stable idempotency key, so a retry sends one text
- Work retries, dead-letter queues, carrier failover, rate limiting, and signed status webhooks
Understanding the problem
A business wires your API into its app to reach customers by text. A bank fires a login code the instant someone taps sign in. A pharmacy queues a refill reminder for the morning it is due. A retailer uploads a list of two million shoppers and asks you to text every one of them about a sale. Each sender makes an API call and expects the same thing: the message reaches the right handset within seconds, and they find out whether it did. Between your API and that handset sit the phone carriers, third parties that throttle, reject and go dark on their own schedule. Design the system that accepts a submitted message and delivers it, at the scale of a global messaging platform, without losing it and without sending it twice.
This system has a quiet half and a loud half. The quiet half is a developer wiring up the API: a handful of test sends, a webhook URL, a check that the code arrives. It asks almost nothing of the machine. The loud half is production, and it is where the interview lives. Millions of messages an hour. Carriers that throttle and drop. A two-factor code that is worthless if it lands a minute late, and a customer billed for every send and furious about a double one. The loud half is where one simple-looking API call turns into a hard distributed-systems problem.
Name the hard part before drawing a box. Sending one SMS is a solved problem; a single call to one carrier's API does it. The work is everything around that call. It is accepting a submit and never losing it, even when a worker dies between the accept and the send. It is delivering through a carrier that is rejecting half your traffic this minute. It means refusing to send the same text twice when a retry fires. It means turning away load you can't carry instead of melting, and reporting back to a sender who is already gone. Get those right and the SMS itself is the easy part.
Functional requirements
Scope to the path that carries the weight and name the rest to set it aside. Six things make the core. A sender submits a message, or a batch, over the API. The system validates it, rate-limits it and accepts it durably. It delivers each message through a carrier, retrying and failing over when the carrier balks. It guarantees at-least-once delivery deduped by an idempotency key, so a real submit always goes and a retry never doubles it. It tracks each message's status as the carrier reports back. And it delivers that status to the sender over a signed webhook. Everything else you name and drop.
In scope
- Accept a message or a batch submit over the API, validate it and enqueue it durably
- Expand a batch or broadcast into one message per recipient
- Deliver each message through a carrier, with retries, backoff and failover to a backup route
- Guarantee at-least-once delivery, deduped by an idempotency key so a retried submit sends once
- Rate-limit per account and shed load past a global ceiling with a fast rejection
- Track each message's status and deliver signed status webhooks back to the sender
Below the line (name it, then skip)
- The carrier and telco networks themselves; we hand a message to a gateway, we do not run the cell towers
- Message content, templating and the sender's own application logic that decides what to say
- Number provisioning and compliance like opt-out and STOP handling, beyond calling the hook
- Billing and pricing; we emit a usage event per message and a separate system prices it
- Voice, email and push channels, which reuse this exact spine but are their own prompts
Non-functional requirements
These are the requirements that pick the design, so put a number on each. Delivery is near-real-time but not instant, and it is asynchronous. A login code should reach the handset within seconds. Nobody is blocked at a checkout waiting on the API, though, so the submit call can return the moment the message is safely accepted, and the slow work of reaching a carrier happens after. That one fact, that the caller does not wait for delivery, is what separates this from a payments API and what lets the whole pipeline be a queue.
What must not slip is the pair of guarantees that pull against each other. A submitted message must not vanish, so delivery is at-least-once and survives a crash. And a message must not go twice, so the pipeline is idempotent. A dropped two-factor code locks a user out of their account. A doubled marketing text is a compliance incident and a second charge on the bill. Holding both at once, never lost and never duplicated, across carriers you do not control, is the spine of the whole design.
Now the shape of the load. Assume hundreds of thousands of sending accounts and, at peak, on the order of tens of thousands of submits a second as two-factor surges and marketing campaigns overlap. The raw submit rate is not the scary number, though. Two things are. A single broadcast can expand from one API call into millions of messages in a burst. And every one of those waits on a carrier that takes hundreds of milliseconds to seconds to accept it, then returns its delivery receipt seconds to minutes later. The system is sized by the in-flight backlog those two facts create, not by the submits per second at the front door.
- Peak submit rate: ~tens of thousands/sec. Two-factor surges and marketing campaigns overlap and burst. Each submit is a cheap write plus an enqueue, so the accept tier scales flat behind a load balancer.
- Broadcast fan-out: 1 call to millions. A single send-to-my-list request expands server-side into one message per recipient. This amplification, not the request rate, is the burst the queue has to absorb.
- Carrier path: async, seconds to accept. A carrier takes hundreds of ms to seconds to accept, then returns a delivery receipt seconds to minutes later. The result comes back on its own clock and out of order.
- Delivery guarantee: at-least-once, deduped. A real submit always reaches a carrier and a retry never doubles the send. Near-real-time is the bar, not milliseconds, which is what lets the pipeline be asynchronous.
The set-up
Core entities
Name the objects a message is built from before drawing anything. Four carry the design, and the one that quietly does the most work is the token bucket, because it runs on every submit before anything durable happens.
Message
One text to one recipient: the destination and sender numbers, the body, the owning account, an idempotency key, a status and a set of timestamps. Its status walks from accepted to sent to delivered or failed. This is the record every other part of the system reads, writes and reports on.
Submit request
The API call a sender makes to send. It carries the idempotency key and is either one message or a batch that names a whole list. A batch is expanded server-side into one Message per recipient, so a single call to text two million people becomes two million jobs on the queue.
Rate-limit bucket
The per-account token bucket, plus a global one, that decides accept or reject at the door. It refills at the account's allowed rate and holds a small burst. Every submit consults it before anything durable happens, so it lives in a fast in-memory store, not the database.
Delivery receipt
The asynchronous status a carrier sends back after it has tried to deliver: delivered, or failed with an error code that says why. It arrives seconds to minutes after the send, out of order with respect to other messages, and it is what drives the status webhook to the sender.
The whole design leans on one line. A submit is a promise to accept the message durably and deliver it reliably soon, which is easier to guarantee than sending right now. Splitting accept from deliver is what lets the front door stay fast under a burst while the back of the system grinds through carriers at their own pace. Every design choice below follows from that split.
The stores fall out of the work each part does. The durable home of the messages is a wide-column store like Cassandra, keyed by message id and indexed by account, because the reads are point lookups by id and by account, never joins. The rate-limit buckets and the idempotency keys live in Redis, because they are tiny, hot and touched on every single submit. The message queue and the webhook queue both ride Kafka, the backbone a Java and AWS shop like Twilio already runs. The Redis, Kafka and Cassandra lessons cover why each store fits its job.
API
The surface a sender touches is small, and it keeps the fast accept path apart from the slow delivery. Submitting a message is a write that returns immediately. Reading a message status is a plain lookup. The carrier's delivery receipts are not a public endpoint at all; they arrive as a stream the status service consumes. And the status callback is an outbound POST the sender receives, signed so they can trust it.
Two things on that surface carry the design. The idempotency key on the submit makes at-least-once safe end to end. Every hop after it can retry, and one submit must still cost the customer one text. The other is the signed status callback. It carries the delivery result to a sender who left milliseconds after asking. The signature tells them the result is real and not a forged POST to their public URL.
High-level design
Walk one message through the system, the simplest correct version first. The diagram colors each box by role. The sender and the external carrier gateways are blue. The services are violet: the ingest API, the sender workers, the status service and the webhook dispatcher. The message queue and the webhook queue on Kafka are amber. The message store and the rate-limit buckets are green.
1) A sender submits. The sender calls the ingest API with a destination, a body and an idempotency key. It takes a token from the account's rate-limit bucket, claims the idempotency key as it writes the message down as accepted in the message store, then enqueues the job on the message queue. Then it answers 202. The caller is done in milliseconds and nothing has been sent yet, by design.
2) A worker delivers. A sender worker pulls the job off the message queue, picks a carrier route for that destination and hands the message to the carrier gateway. It records the attempt in the message store. If the carrier accepts, the status moves to sent. If the carrier throttles or times out, the worker backs off and retries, and after a few misses it fails the message over to a backup route.
3) The carrier reports back. Delivery is asynchronous, so the answer comes later and on its own schedule. The carrier returns a delivery receipt, delivered or failed with a reason, and the status service consumes it. The service updates the message's status in the message store and puts a status event on the webhook queue.
4) The sender hears the result. The webhook dispatcher pulls each status event, signs it with the account's secret and POSTs it to the sender's callback URL. Webhook delivery is itself at-least-once, so it retries a callback endpoint that is down and the sender dedups on the message id. The loop closes. The sender submitted a message and learned, a beat later, that it reached the handset.
Size the system
Work the arithmetic, because it sets the whole story. Start with the accept path. Hundreds of thousands of accounts submit at a peak of tens of thousands a second, and each submit is a token check, a key claim and an enqueue. That is cheap and stateless, so the ingest tier is a flat fleet behind a load balancer, sized to the submit rate and nothing more.
The number that sizes the fleet is downstream. A worker that sends a message does not get an instant answer; it waits on a carrier for hundreds of milliseconds, sometimes seconds, and the delivery receipt trails by seconds to minutes more. So each in-flight message holds a worker slot far longer than the submit took to accept. A broadcast that dumps millions of messages onto the queue at once builds a deep backlog. Size the worker fleet and the queue for that backlog and that carrier latency, because a spike here is one API call becoming millions of slow, blocking sends.
- Retained messages: billions of records. Every message keeps a status record with its history, retained for the sender to query and for billing. Point reads and writes by id, which is what a wide-column store is built for.
- In-flight per worker: carrier-latency bound. A send blocks on a carrier for hundreds of ms to seconds, so the worker slot is held far longer than the submit was. Queue depth, not submit rate, sets the fleet size.
- Backlog spike: 1 broadcast to millions. One list send dumps millions of jobs on the queue at once. The durable queue absorbs the burst so the accept path never blocks on the delivery path behind it.
- Load shed: 429 past the ceiling. Past a per-account quota or the global limit, the system rejects fast rather than growing an unbounded queue that hides the overload and misses every deadline.
Technology choices
Each box is a real category of tool, and Twilio is a Java and Spring shop on AWS, so the picks below are the ones the interviewer is listening for. Name a concrete tool, say why that kind of tool fits the job, and keep the failure question in your pocket.
- Kafka carries the message queue and the webhook queue. Partition the message queue by account so one customer's two-million-recipient blast rides its own partitions and cannot starve a bank's login codes stuck behind it. Because the log is durable and consumption is at-least-once, a worker that dies mid-send resumes from its last committed offset and redelivers, which is safe only because the sink dedups. The Kafka and async processing lessons cover the queue guarantees under this.
- Redis holds the rate-limit buckets and the idempotency keys. Both are hot-path, point-access state that every submit touches, so they belong in memory rather than in the durable store. A submit is one atomic pair of operations: take a token or reject, then claim the dedup key or skip. The handling contention lesson covers the atomic claim that makes both safe under concurrency.
- Cassandra is the durable home of the messages, keyed by message id for the status read and indexed by account for the sender's history view. The workload is billions of small records, written once and updated a few times as the status moves. They're read by point lookup and never joined, the shape a wide-column store serves and a relational one strains under.
- The ingest API, the sender workers and the status services are stateless Spring services, scaled by adding instances and consumers as load grows. Every piece of state lives in the datastores, so any instance handles any submit or any job, and a box dying costs only its in-flight work, which redelivery replaces.
- The carriers are external and unreliable, reached over SMPP or an HTTP API, and they set the terms: they throttle, they fail, they answer slowly. The delivery half treats every send as best-effort and routes around a bad carrier rather than trusting any single one. That single fact, more than any traffic number, is what shapes the retries, the failover and the honest at-least-once guarantee.
Potential deep dives
The deep dives carry most of the grading. The interviewer takes one property of the system and pushes on it until it bends, and for a messaging platform there are four places it bends. Each is a real Twilio interview prompt in its own right, from the SMS-delivery design to the blunt rate-limit question about ten thousand requests a second.
1) At-least-once delivery and idempotency
This is the crux, and the guarantee the whole platform is judged on. A submit can be retried when the sender's connection blips. The queue can redeliver a job after a worker crash. A worker can wake up unsure whether its last send went out. Every one of those wants to send again, and sending again means the customer's user gets a second login code for one request. Two failures pull against each other here: losing the message, and sending it twice. Both are unacceptable. The design has to shut out both at once.
Approach
The ingest API calls the carrier inline and returns once the carrier answers. No queue, no stored key, no retry beyond the one call.
Challenges
A carrier blip or an API crash drops the message with nobody the wiser, which is the one outcome a messaging platform cannot afford. Add a retry to fix that and the opposite failure appears: the first attempt went through, the retry sends the same text again, and the customer's user gets two login codes. With nothing durable recording the attempt, you cannot be both reliable and single-sending.
Approach
Accept the submit onto the durable message queue and return 202. A worker consumes it, sends to the carrier, and commits its offset only after the carrier accepts. A transient failure is retried with backoff; a message that keeps failing lands in a dead-letter queue instead of blocking the partition.
Challenges
Now nothing is silently lost, which is the guarantee that matters most. At-least-once carries a cost, though: any send that succeeded but whose offset commit did not will replay, so duplicate sends are not a rare edge, they are guaranteed to happen. Without a dedup step the customer is billed twice and their user is texted twice for one submit.
Approach
Give every submit a stable idempotency key, either the client's supplied key or one derived from the account and the message. Before a worker sends, it claims that key with one atomic set-if-absent in the fast store; the worker that wins the claim sends, and a replay finds the key already held and skips. Recognizing the duplicate and deciding to send collapse into a single guarded write, so a retried submit or a redelivered job resolves to exactly one text on the wire.
Challenges
The key has to be identical across retries and scoped to the message, not the batch, or a genuinely separate later message gets swallowed. The claim store must be fast and durable, since every send waits on it. Inside the system this is airtight. The one gap is a carrier that accepts a message and never delivers it, which no sender can see. That's why the guarantee is honestly at-least-once and no stronger.
The handling contention lesson covers the atomic claim the sink uses to turn a duplicate send into a no-op, the same primitive the rate limiter leans on below.
2) Retries, failover and the dead-letter queue
A worker hands a message to a carrier, and the carrier is the least reliable box in the design. It throttles you when you push too fast. It returns a 500 that means try again and a 400 that means it never will. It goes down in one country while the rest of the world is fine. The delivery half has to keep trying without hammering a carrier that is already struggling, and without letting one bad destination block every other message behind it.
Retry blindly on one carrier
- Retries the instant a send fails, as fast as the loop runs
- Hammers a throttling carrier harder, deepening the outage it is reacting to
- Retries a permanently bad number forever, burning the queue on a message that can never land
- One carrier down means every message to that destination fails, with no way around it
Back off, classify, fail over
- Exponential backoff with jitter spaces retries so a struggling carrier can recover
- Splits transient errors from permanent ones and stops retrying what will never succeed
- Fails a message over to a backup carrier route after a few attempts on the primary
- Trips a circuit breaker on a carrier that is globally failing, so sends stop pouring into a black hole
- Back off with jitter. Space each retry further out and randomize it. Otherwise a fleet of workers reacting to the same carrier blip retries in one synchronized wave and knocks the carrier back down the instant it recovers.
- Classify the error before retrying. A carrier's transient 500 or a rate-limit response is worth another attempt; a permanent rejection like an invalid number or a blocked-content code is not. Retrying a permanent failure wastes the queue and delays real traffic, so a permanent error goes straight to failed.
- Fail over to a backup route. Most destinations are reachable through more than one carrier, so after a few failed attempts on the primary, re-route the message to a secondary. This is the difference between a carrier outage being your problem or the customer's.
- Circuit-break a dead carrier. When a carrier's error rate crosses a threshold, trip a breaker and stop sending to it for a cool-down, routing everything to alternates. Occasional probe sends test when it is healthy again. It stops the fleet from pouring millions of doomed messages into a carrier that is simply down.
- Dead-letter what exhausts its retries. A message that has failed every route and every attempt is parked in a dead-letter queue with its error trail, not dropped. It can be inspected, replayed after a fix, or surfaced to the sender as a hard failure through the status webhook.
3) Rate limiting and shedding load
Twilio asks this one almost verbatim: design a service that takes ten thousand requests a second and rejects everything past that. Behind the blunt version are two real jobs. One is fairness, because each account has a plan that allows so many messages a second and one customer's campaign must not starve everyone else. The other is self-protection, because the platform as a whole can only push so much to carriers, so past that ceiling it has to turn work away on purpose rather than fall over. Both are the same primitive, a rate limiter, applied at two different scopes.
Approach
Keep a per-account counter in the database. On every submit, read the count, check it against the account's limit, increment it and write it back.
Challenges
Every submit now pays a database round-trip on the hot path, and the read-modify-write races: two submits read the same count, both think they are under the limit, and the cap leaks. Worse, at tens of thousands of submits a second that counter row becomes a contention hot spot that serializes the whole ingest. It is a limiter that cannot keep up with the traffic it is meant to limit.
Approach
Give each account a token bucket in Redis, refilled at its allowed rate up to a burst ceiling. A submit atomically takes one token or is rejected with a 429. The check is a single in-memory operation, off the durable database entirely.
Challenges
This is O(1), it holds the per-account rate honestly, and it never touches the message store. The gap is the global picture. A single Redis is one bottleneck and one point of failure. Per-account buckets alone don't protect the platform when thousands of accounts burst at once past what the fleet can carry.
Approach
Shard the buckets across the Redis fleet by account id, so each account's key lives on one shard and no submit needs cross-shard coordination. Layer a global ceiling on top. Give each ingest node a slice of the total budget, or keep a coarse global counter, so the system sheds overload past the number it can deliver. Reject fast with a 429 and a Retry-After, and never queue past the ceiling.
Challenges
The per-account bucket and the global shed answer two different questions, one about fair use and one about self-protection, and the design needs both. The residual work is keeping the shards balanced and the global count cheap, since every submit consults it. Done right, an account that floods gets throttled to its plan and the platform as a whole degrades gracefully instead of falling over.
4) Delivery status and the status webhook
The sender is long gone by the time the carrier says whether the message landed. The submit returned 202 in milliseconds; the delivery receipt shows up seconds to minutes later, on a different connection entirely. The status webhook is how that late answer gets back to the sender's application. Three problems stack up: reliability, ordering and trust. Take them one at a time.
- Deliver the callback at-least-once. Put every status event on a durable webhook queue, POST it to the sender's URL, and retry a failing endpoint with backoff before dead-lettering it. The sender's server can be down for a deploy; the delivery result must survive that and arrive when they come back.
- Make the sender dedupe. At-least-once means the same status can arrive more than once, so stamp each callback with the message id and the status and have the sender treat a repeat as a no-op. The same idempotency discipline that protects the send path protects the sender consuming your callbacks.
- Do not promise an ordering you cannot keep. Carrier receipts arrive out of order, so a delivered can land before the sent that logically preceded it. Stamp each status with the carrier's timestamp or a sequence number and keep only the latest, so a stale earlier status never overwrites a newer one in your store or theirs.
- Sign every callback. The sender's URL is public, so anyone could POST a fake delivered to it. Sign each callback with a secret shared with the account, an HMAC over the payload, so the sender can verify it came from you and reject a forgery. Without the signature the status stream is trivially spoofable.
What is expected at each level
That is a lot of surface for one prompt, and how much of it you have to land scales with the level. Twilio gates this round: lighter for an early engineer, a real bar from the senior band up, a genuine grilling at staff. One Twilio note before the tiers. Several Twilio design rounds lean low-level, asking for the API contract and message schema as much as the boxes. State the submit API and the message model precisely here, the way the API-design round rewards.
Software Engineer 2
Breadth carries it. Get to a correct spine: a sender submits over the API, the message lands on a durable queue, a worker delivers it to a carrier, and a status flows back. Say why the submit cannot send inline, because a carrier is slow and a burst is large, so the queue absorbs both. Know that delivery is at-least-once and needs an idempotency key so one submit is not sent twice. The rate limiter and the carrier failover are the interviewer's to raise. The bar is a clean end-to-end path and the instinct that delivery, not the API call, is the hard part.
Software Engineer 3 and Senior
Cover the breadth, then own idempotency and delivery. You are expected to reach the durable-queue-plus-idempotent-claim design without prompting, and to explain why exactly-once through a carrier is off the table. Build the retries with backoff, a dead-letter queue and failover to a backup carrier. Build the rate limiter as a token bucket that sheds with a 429 rather than an unbounded buffer. Treat a double-sent text as a real failure. The bar is a design that stays correct under a carrier outage and a traffic spike, with each store justified as a deliberate pick.
Staff and above
Depth everywhere the system bends, and a widening frame. Lead on the carrier layer: the failover routing, the per-carrier circuit breaker and the error classification that decides what is even worth a retry. Design the sharded rate limiter with a global shed, and the status webhook with its ordering, its signing and its at-least-once contract. Reason about the consistency between the fast Redis state and the durable message store, and how it is kept from drifting. Then widen the frame: the same spine carries voice, email and push, so design the delivery and status platform once, not a one-off for SMS. The bar is that reliability, no double-send and overload all stay central at once while you drive and the interviewer redirects.
Key Takeaways
- ✓The system splits accept from deliver. The ingest API durably accepts a submit, checks the rate limit, claims an idempotency key and enqueues, then returns 202 in milliseconds; the workers reach carriers off the caller's clock. That split is what absorbs a two-million-message broadcast and survives a carrier outage without ever blocking the front door.
- ✓Deliver at-least-once and dedupe at the sink. A submitted message must reach a carrier, so the queue is durable and a failed send retries into a dead-letter queue. A stable idempotency key, claimed with one atomic write, makes a retried submit or a redelivered job a no-op. Exactly-once through a carrier you do not own is not on the table, so retry hard and make the second send harmless.
- ✓Carriers are unreliable third parties, so routing around them is the product. Tell a transient failure from a permanent one and stop retrying what can never land. Back off with jitter so a struggling carrier can recover. Then fail a message over to a backup route and trip a circuit breaker on a carrier that has gone dark. Whatever exhausts every route goes to the dead-letter queue.
- ✓Shed overload, do not buffer it. Past a per-account quota or the global ceiling, reject fast with a 429 and a Retry-After instead of growing an unbounded queue that hides the overload and blows every latency budget. Then close the loop honestly: the async delivery result flows back through signed, at-least-once status webhooks the sender can dedupe and trust.
Twilio interview guideKnow what Twilio asks before you walk in.
9 coding · 3 system design · 18 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.