The senior interview all 4 written
The three things you are actually judged on, and how to answer a mobile system design question.
36 What seniors are really assessed on
A mid-level interview asks whether you can build the thing. A senior interview asks whether you can decide what to build, say why, and be trusted with what happens when the decision is wrong. Most candidates prepare only for the first one, then cannot understand why a clean answer did not land.
The gap
Both interviews look similar from the outside. Someone describes a feature, you talk about how you would do it. The difference is what the notes say afterwards.
- Mid-level notes — did they know the API, was the state in a sensible place, would this code pass review, can they debug with a stack trace in front of them.
- Senior notes — did they ask what the requirement actually was, did they name what their choice costs, did they say how they would know it worked, and would I let them make this call without me in the room.
That last line is the whole thing. A senior hire is a delegation of risk. Everything in the interview is an attempt to work out how much of the blast radius you can be given, and "I have used Bloc for four years" answers none of it.
The seven things being scored
Each of these gets probed whether or not the interviewer has a name for it. The weak and strong versions below are the actual sentences, because the gap between them is smaller than people expect and it is almost entirely about what you volunteer unprompted.
- Scoping and requirement negotiation. Probed by giving you a brief that is deliberately underspecified. Weak: starts designing in the first thirty seconds. Strong: "Before I design this — what does fast mean here, on which device, and is that a median or a worst case? I will assume p90 under two seconds on a four-year-old mid-range Android unless you tell me otherwise." Assumptions stated out loud are worth as much as questions, and they are faster.
- Tradeoff articulation. Probed by asking why you chose something. Weak: "Riverpod, because it is more flexible." That is a preference with a compliment attached. Strong: names the cost in the same breath — "Riverpod, and the cost is that the dependency graph is not visible in any one file, so a new joiner takes longer to trace a value. On a team of nine I would take Bloc instead for exactly that reason." A choice with no stated cost reads as a choice you have never had to live with.
- Measurement instinct. Probed by "how would you know if that worked?" Weak: "We would test it and see if it feels better." Strong: one number, its source, and its current value — "Frames over budget during the feed scroll, from production timing callbacks, currently fifteen per cent. I would want that under two, and I would look at it per device tier because the average will hide the cheap phones."
- Blast radius and rollback awareness. Probed by "what happens when this is wrong in production?" Weak: "We would fix it and ship an update." A mobile release takes a day to review and weeks to reach everyone, so that answer means the bug is live for a fortnight. Strong: "It goes behind a remote flag, defaulted off, rolled out to one per cent. The failure mode is a corrupted local cache, so the migration is additive and the flag also controls whether we read the new schema. Rollback is a flag flip, not a release."
- Debugging method. Probed by a symptom with no cause — the feed is
janky, the app is crashing on one device family. Weak: a list of plausible causes,
confidently. Strong: the order in which you would get evidence, and what each step rules
out. This is lesson 33 in one sentence: reproduce it in profile mode on the device it
happens on, read the frame chart, say which thread, and only then choose the fix. The
candidate who says
constandRepaintBoundarybefore looking at anything has told you they guess. - Code review and mentoring. Probed by "how do you review?" or "tell me about disagreeing with someone". Weak: "I check for best practices." Strong: makes the codebase better through other people. "I separate the two kinds of comment. If it is correctness or it will page someone, I block and say why. If it is taste, I say it is taste and approve anyway. The third kind is the one that matters: if the same comment shows up in three reviews, the comment is wrong — it should be a lint rule, a template, or a conversation about the design."
- Saying "I do not know" well. Probed constantly, because every interviewer eventually reaches the edge of what you have done. Weak: guessing, which is the single fastest way to lose a senior interview, because the interviewer now has to discount everything else you said. Also weak: a flat "no idea", which stops the conversation. Strong: "I have not used isolate groups in production, so I would be guessing about the memory overhead. What I would do is measure it against the compute isolate we already have, on the two devices where the parse is slowest, and I would want to see the peak RSS before I moved anything." That answer is worth more than a correct guess, and interviewers know it.
Turning a slogan into a number
Here is the move that separates the two interviews, and it is almost mechanical. Someone in the room says a comforting sentence — "a one per cent crash rate is fine at our scale" — and you convert it into people and days before agreeing to it.
/// A quality target as arithmetic rather than a slogan. Crash-free is a rate over
/// SESSIONS, so it is held here in basis points: 9900 is 99 per cent, 9990 is 99.9.
/// Integers all the way through, because 1 - 0.99 in binary floating point is not
/// 0.01 and the number you take into the room should not depend on that.
int crashesPerDay({required int sessions, required int crashFreeBp}) =>
sessions * (10000 - crashFreeBp) ~/ 10000;
/// What a month of one-star reviews does to a store average. Store ranking is far
/// more complicated than this. The direction is not.
double ratingAfter({
required int reviews,
required double average,
required int oneStars,
}) =>
(reviews * average + oneStars) / (reviews + oneStars);
void main() {
const dau = 2000000;
// One session per user per day, so the two numbers coincide and the sentence
// stays honest: 20,000 sessions crashed, and 20,000 people were in them.
final at99 = crashesPerDay(sessions: dau, crashFreeBp: 9900);
final at999 = crashesPerDay(sessions: dau, crashFreeBp: 9990);
final monthAt99 = at99 * 30;
final monthAt999 = at999 * 30;
// One person in two hundred who hits a crash goes to the store and leaves one
// star. Support can tell you the real ratio; they are the ones reading them.
final rating99 =
ratingAfter(reviews: 50000, average: 4.6, oneStars: monthAt99 ~/ 200);
final rating999 =
ratingAfter(reviews: 50000, average: 4.6, oneStars: monthAt999 ~/ 200);
print('99% crash free -> $at99 a day, $monthAt99 a month, '
'rating ${rating99.toStringAsFixed(2)}');
print('99.9% crash free -> $at999 a day, $monthAt999 a month, '
'rating ${rating999.toStringAsFixed(2)}');
test('at two million daily sessions, 99 per cent crash free is 20,000 crashes a day',
at99, 20000);
test('which over a thirty day month is 600,000 crash events', monthAt99, 600000);
test('the metric is per session, so two sessions a user doubles it',
crashesPerDay(sessions: dau * 2, crashFreeBp: 9900), 40000);
test('the same app at three nines is 2,000 a day instead', at999, 2000);
test('a month at 99 per cent drags a 4.6 store rating down to 4.40',
rating99.toStringAsFixed(2), '4.40');
test('a month at 99.9 per cent barely moves it', rating999.toStringAsFixed(2), '4.58');
test('so the gap between those two targets is a fifth of a star, every month',
[4.6 - rating99 > 0.2, 4.6 - rating999 < 0.03], [true, true]);
}
What it printed, when the build ran it
99% crash free -> 20000 a day, 600000 a month, rating 4.40 99.9% crash free -> 2000 a day, 60000 a month, rating 4.58 ok at two million daily sessions, 99 per cent crash free is 20,000 crashes a day ok which over a thirty day month is 600,000 crash events ok the metric is per session, so two sessions a user doubles it ok the same app at three nines is 2,000 a day instead ok a month at 99 per cent drags a 4.6 store rating down to 4.40 ok a month at 99.9 per cent barely moves it ok so the gap between those two targets is a fifth of a star, every month
Say the middle number out loud and the conversation changes: "Ninety-nine per cent crash-free at our scale is twenty thousand people a day, six hundred thousand a month. Is that the number we are choosing?" Nobody defends that sentence. And note what the panel does not claim — it does not say 99.9 is worth any amount of engineering. It puts a cost on both sides so the product manager can make the call with real quantities. That is the job: not caring more about quality than everyone else, but making the tradeoff arguable.
Turning a requirement into acceptance criteria
"The app should feel fast" cannot be built, tested, or disagreed with. A percentile budget can be all three. The percentile is the point: averages are the enemy here, because the slowest launches are on the cheapest phones, and the cheapest phones are exactly where your growth is.
/// Nearest rank on an already sorted list: the p-th percentile is the value at
/// index ceil(n * p / 100) - 1, for p from 1 to 100. So the p50 of twenty samples
/// is the tenth value, and the p99 of twenty samples is the twentieth - which is
/// the maximum. That is a real limit of the measurement, not a rounding detail.
int percentile(List<int> sorted, int p) {
final rank = (sorted.length * p / 100).ceil();
return sorted[rank - 1];
}
double mean(List<int> xs) => xs.reduce((a, b) => a + b) / xs.length;
/// The budget as lines that pass or fail. This is what goes in the ticket
/// instead of the word "fast".
List<String> breaches(List<int> sorted, Map<int, int> budgetMs) => [
for (final line in budgetMs.entries)
if (percentile(sorted, line.key) > line.value) 'p${line.key}',
];
void main() {
// Twenty cold starts from one build, in milliseconds, already sorted. The tail
// is two devices with a cold filesystem cache and a slow first network call.
const starts = <int>[
700, 720, 740, 760, 780, 800, 810, 820, 830, 850, //
870, 900, 950, 980, 1000, 1100, 1190, 1400, 2600, 6200,
];
// What was actually agreed, once someone asked what fast meant.
const budget = {50: 1000, 90: 2000, 99: 3000};
// A hundred samples, to show where a p99 stops being the maximum.
final hundred = [for (var i = 1; i <= 100; i++) i * 10];
print('mean ${mean(starts)}ms p50 ${percentile(starts, 50)}ms '
'p90 ${percentile(starts, 90)}ms p99 ${percentile(starts, 99)}ms');
print('lines breached: ${breaches(starts, budget)}');
test('the mean cold start is 1250ms, which nobody would escalate',
close(mean(starts), 1250.0), true);
test('the median is 850ms, so half the launches are quicker than that',
percentile(starts, 50), 850);
test('the p90 is 1400ms, inside the 2000ms line that was agreed',
[percentile(starts, 90), percentile(starts, 90) <= 2000], [1400, true]);
test('the p99 is 6200ms, which is a six second launch', percentile(starts, 99), 6200);
test('so the mean sits inside a budget the tail is nowhere near',
[mean(starts) <= 3000, percentile(starts, 99) <= 3000], [true, false]);
test('one line fails, and it is the only one users write reviews about',
breaches(starts, budget), ['p99']);
test('with twenty samples a p99 IS the maximum; a hundred is where that stops',
[percentile(starts, 99) == starts.last, percentile(hundred, 99) == hundred.last],
[true, false]);
}
What it printed, when the build ran it
mean 1250.0ms p50 850ms p90 1400ms p99 6200ms lines breached: [p99] ok the mean cold start is 1250ms, which nobody would escalate ok the median is 850ms, so half the launches are quicker than that ok the p90 is 1400ms, inside the 2000ms line that was agreed ok the p99 is 6200ms, which is a six second launch ok so the mean sits inside a budget the tail is nowhere near ok one line fails, and it is the only one users write reviews about ok with twenty samples a p99 IS the maximum; a hundred is where that stops
Two things in that panel are worth saying unprompted. First, the mean passed and the p50 passed and the p90 passed, and the release is still wrong, because one launch in twenty took six seconds and that user has already decided what they think of your app. Second, and this is the detail that marks someone who has actually done it: with twenty samples the p99 and the maximum are the same number, so quoting a p99 off twenty measurements is quoting your worst outlier and calling it statistics. You need roughly a hundred samples before the p99 means anything and a few thousand before it is stable, which is an argument for production telemetry rather than a lab.
The first two minutes
Most candidates lose a design question in the opening, by answering the question they were asked instead of the one that was meant. This is the script. It is not clever, and it works on every prompt.
// Before you design anything, get these on the whiteboard. Three or four is
// plenty - this is a conversation, not an interrogation - and you pick the ones
// that would change your answer most.
//
// SCALE
// How many users, and how many of them at once?
// Which devices, and what is the oldest one we support?
// Which markets? "Works in Lagos on 2G" is a different app from "works in Zurich".
//
// THE WORD THEY USED
// Whatever adjective was in the brief - fast, reliable, real time, offline -
// ask for it as a number with a percentile and a device attached.
// "Real time" is between 200ms and 5s depending on who said it. Find out which.
//
// CORRECTNESS
// What is the worst thing that can happen if this is wrong?
// Can two people edit the same thing? (Lesson 28 starts here.)
// Is stale data acceptable, and for how long?
//
// CONSTRAINTS
// What already exists that I should be using instead of building?
// Who owns the API, and can it change? This one decides half the design.
// When does it have to ship, and what is the actual deadline for?
//
// THEN STATE YOUR ASSUMPTIONS OUT LOUD AND MOVE ON
// "I am going to assume ten thousand daily users, feed is chronological, and
// stale by up to five minutes is fine. Stop me if any of those is wrong."
// An assumption stated is as good as an answer received, and it is quicker.
// Two minutes of this, then design. Ten minutes of it and you are stalling,
// which reads exactly like not knowing.
//
// WHAT THIS IS ACTUALLY FOR
// Not politeness. Every question above removes a branch of the design, and the
// interviewer is watching to see whether you know which branches exist. A
// candidate who asks "can users post while offline" has told them they know
// that answer costs an outbox, an id strategy and a conflict policy.
This is a script for a conversation, not code. There is nothing to execute: the value is in the order of the questions and in stopping to hear the answers.
How to prepare, honestly
Short list, because the long ones are procrastination. None of this is about revising Flutter.
- Know your last two projects deeply enough to be cross-examined. Not the summary — the decisions. Why that state library, who else was on the team, what the release cadence was, which part you would build differently now. A senior interview eventually becomes an interrogation of one thing you actually did, and vagueness there is read as not having really owned it.
- Have numbers. Users, crash-free rate, cold start, bundle size, test count, release cadence, the size of the codebase, how long CI took. If you cannot remember exact figures, an honest estimate with its basis is fine — "around forty thousand monthly, I saw the dashboard weekly" — and it is enormously better than none. Every number you have is evidence you were paying attention to something beyond your own tickets.
- Have one story where you were wrong. A real one, with the cost. What you believed, what the evidence turned out to be, what it cost, what you changed about how you work. Candidates avoid this question because it feels like a trap; it is the opposite. A senior who has never been wrong is a senior who has never been measured.
- Have one story about a person, not a system. Someone you unblocked, a review that changed a design, a disagreement you lost gracefully. Half of a senior role is other people, and if every answer you give is about code, the notes will say so.
- Practise saying the cost. Take five decisions you have made and write the sentence "I chose X, which cost us Y, and I would take that again because Z" for each. This is the single highest-yield hour of preparation available, because that sentence shape is what is being scored in nearly every question.
The compression of this whole lesson: an interviewer is trying to predict what you will do on a Tuesday afternoon when a vague ticket lands and nobody is watching. Everything they can see is the shape of your reasoning. So make the reasoning the visible part — ask what the requirement means, put a number on it, name what your choice costs, say how you would know it worked, and say what happens when it is not. Lesson 37 does exactly that, once, end to end, on a real prompt.
37 A mobile system design answer, worked through
One prompt, answered end to end, so the shape is visible: design the feed for a social app that must work offline on a poor connection. Four phases, in this order, and the last one is the one candidates leave out.
Phase 1 — clarify, before designing anything
Two minutes, no more. Each of these questions removes a branch of the design, and naming the branch is most of the value — the interviewer learns that you know it exists.
- How many items does a session look at? Twenty changes nothing and two thousand changes everything. Under a few hundred, you can hold the whole cached window in memory and keep it simple. Beyond that you need a windowed local store with eviction, and a decision about what to throw away first.
- Ranked or chronological? This is the biggest one and it is usually asked last. Chronological means the feed has a stable sort key, which means a cursor works, which means caching is straightforward. Ranked means the server can reorder between requests, so a cursor has to be an opaque server-side token holding the ranking session — and it means a cached page can be stale in a way the user cannot see or refresh past.
- Can users post while offline? If yes, I have signed up for the whole of lesson 28: a persisted outbox, device-generated ids for idempotency, and a pending state in the UI. If no, the app is a read cache and this is a much smaller piece of work. Ask, do not assume, and say which one you costed.
- Must edits and deletes sync, or only new posts? Append-only sync is nearly free. Mutable items mean two writers on one record and a conflict policy per field. Deletes mean tombstones, or the item comes back from the cache after the server dropped it.
- How stale is acceptable? Five minutes and thirty seconds are different architectures: one is a pull-to-refresh with a TTL, the other wants a push or a subscription and a merge into a list the user is currently scrolling. I would push hard on this, because teams answer "as fresh as possible" and mean "five minutes is fine".
- Image-heavy or text? On a poor connection this decides where the whole budget goes. If the feed is images, the JSON is a rounding error and the real design is the image pipeline: sizes, caching, placeholders, and what you do when a decode fails.
Then state the assumptions and move: chronological, twenty items a page, posting works offline, edits do not, five minutes of staleness is fine, two images per post. Everything after this is answering that specific question rather than a general one.
Phase 2 — the shape, and which option I take
No new architecture here, and that is the point: this is lesson 24's layering, lesson 27's repository and lesson 28's offline machinery, wired for this brief. What is worth saying in an interview is the choice at each seam and the reason.
- Layers (lesson 24). Presentation, domain, data, arrows inward. The
domain owns
Postand theFeedPortinterface; the data layer implements it. The choice worth defending: I keep the feed's ordering rule in the domain even though the server also sorts, because "newest first, pending posts pinned to the top" is a product rule and I want it testable without a network. - Repository (lesson 27). One
FeedRepository, not one class per endpoint, and it owns the caching decision. The choice: the local store is the single source of truth and the UI watches it. The network writes into the store and the store emits. This is the decision that removes the two-caches bug — the screen never has its own copy to disagree with. - Offline (lesson 28). Reads are stale-while-revalidate from the local store. Writes go into a persisted outbox with device-generated ids, replayed in order, with retryable and permanent failures treated differently. The choice I would defend loudly: posts are append-only in v1, so there is no conflict resolution at all — the cheapest correct answer to a hard problem is arranging not to have it.
- Where state lives (lesson 19). Scroll position and composer text are widget state. The feed itself outlives the route, so it belongs to the repository and is delivered by whatever the team already uses.
Phase 3 — how you paginate, which is the question underneath the question
"How would you paginate the feed" sounds like trivia. It is not: a feed is a list with new items arriving at the head, and page-based pagination is a position in that list. Move the head between two requests and the position now points somewhere else. Here is the bug, concretely.
/// A feed item. The key is whatever the server sorts by - here an increasing int,
/// standing in for a (createdAt, id) pair so that ties have a tiebreak.
typedef Item = ({int key, String title});
class Feed {
Feed(this._items);
/// Newest first, which is what the client wants and why offsets shift.
final List<Item> _items;
void arrive(Item item) => _items.insert(0, item);
void deletePost(int key) => _items.removeWhere((i) => i.key == key);
/// OFFSET: skip n, take m. The offset is a POSITION in a list that other
/// people are editing while you read it.
List<Item> byOffset({required int offset, required int limit}) =>
_items.skip(offset).take(limit).toList();
/// CURSOR, or keyset: everything strictly older than a key you have already
/// been given. The cursor names an ITEM, so edits above it are irrelevant.
List<Item> byCursor({int? after, required int limit}) {
final page = <Item>[];
for (final item in _items) {
if (after != null && item.key >= after) continue;
if (page.length == limit) break;
page.add(item);
}
return page;
}
}
List<int> keysOf(List<Item> page) => [for (final item in page) item.key];
/// Anything the user was shown twice while scrolling.
List<int> shownTwice(List<int> keys) {
final seen = <int>{};
return [for (final k in keys) if (!seen.add(k)) k];
}
void main() {
Feed sixPosts() => Feed([for (var k = 6; k >= 1; k--) (key: k, title: 'post $k')]);
// ---- offset, with one new post arriving between the two requests --------
final offsetFeed = sixPosts();
final o1 = offsetFeed.byOffset(offset: 0, limit: 3);
offsetFeed.arrive((key: 7, title: 'post 7'));
final o2 = offsetFeed.byOffset(offset: 3, limit: 3);
final offsetSeen = [...keysOf(o1), ...keysOf(o2)];
// ---- cursor, exactly the same interleaving ------------------------------
final cursorFeed = sixPosts();
final c1 = cursorFeed.byCursor(limit: 3);
cursorFeed.arrive((key: 7, title: 'post 7'));
final c2 = cursorFeed.byCursor(after: c1.last.key, limit: 3);
final cursorSeen = [...keysOf(c1), ...keysOf(c2)];
// ---- and the other direction: a post is deleted instead of added -------
final offsetDeleted = sixPosts();
final d1 = offsetDeleted.byOffset(offset: 0, limit: 3);
offsetDeleted.deletePost(5);
final d2 = offsetDeleted.byOffset(offset: 3, limit: 3);
final offsetAfterDelete = [...keysOf(d1), ...keysOf(d2)];
final cursorDeleted = sixPosts();
final k1 = cursorDeleted.byCursor(limit: 3);
cursorDeleted.deletePost(5);
final k2 = cursorDeleted.byCursor(after: k1.last.key, limit: 3);
final cursorAfterDelete = [...keysOf(k1), ...keysOf(k2)];
print('offset saw $offsetSeen, twice: ${shownTwice(offsetSeen)}');
print('cursor saw $cursorSeen, twice: ${shownTwice(cursorSeen)}');
print('after a delete, offset saw $offsetAfterDelete');
print('after a delete, cursor saw $cursorAfterDelete');
test('both strategies return the same first page',
[keysOf(o1), keysOf(c1)], [[6, 5, 4], [6, 5, 4]]);
test('a post arrives, and offset page two now starts one item too early',
keysOf(o2), [4, 3, 2]);
test('so post 4 is rendered twice in a single scroll', shownTwice(offsetSeen), [4]);
test('the cursor asked for what is older than post 4, and got exactly that',
keysOf(c2), [3, 2, 1]);
test('so nothing repeats, however much arrives at the head',
shownTwice(cursorSeen), <int>[]);
test('and when a post is deleted instead, offset skips post 3 entirely',
offsetAfterDelete.contains(3), false);
test('while the cursor still delivers it', cursorAfterDelete.contains(3), true);
}
What it printed, when the build ran it
offset saw [6, 5, 4, 4, 3, 2], twice: [4] cursor saw [6, 5, 4, 3, 2, 1], twice: [] after a delete, offset saw [6, 5, 4, 2, 1] after a delete, cursor saw [6, 5, 4, 3, 2, 1] ok both strategies return the same first page ok a post arrives, and offset page two now starts one item too early ok so post 4 is rendered twice in a single scroll ok the cursor asked for what is older than post 4, and got exactly that ok so nothing repeats, however much arrives at the head ok and when a post is deleted instead, offset skips post 3 entirely ok while the cursor still delivers it
Two sentences from that panel are worth memorising, because they are the answer to a question that gets asked constantly and answered badly. "Offset pagination asks for a position and a feed has no stable positions, so an insert at the head makes page two overlap page one and a delete makes it skip. Keyset pagination asks for everything older than the last key I hold, which is stable under both." And the cost, because you always state the cost: a cursor cannot jump to page seven, which is fine for a feed and wrong for a paginated admin table. If the feed is ranked rather than chronological, the cursor has to be an opaque server token that pins the ranking for the session, and that is a server-side conversation you should say you would have.
The client-side half. Duplicates get past review because the symptom is rare and looks like a rendering glitch. Two cheap defences: de-duplicate by id when appending a page, and key list items by post id rather than index — lesson 16's bug wearing a feed costume. Neither fixes the skipped item, which is why the pagination model has to be right rather than patched.
Phase 3, continued — the payload, on the connection they actually have
"Keep the payload small" is a slogan. On a bad cell the arithmetic is short enough to do in front of the interviewer, and it decides the design of the image pipeline.
// Bytes per feed item, three ways of shipping the image. These are uncompressed:
// gzip takes roughly two thirds off JSON text and almost nothing off base64 image
// data, which widens the gap below rather than closing it.
const int textFields = 480; // ids, author, body, counts, timestamps
const int blurPlaceholder = 32; // a blurhash-style string, decoded on device
const int inlineThumb = 8192; // a 6 KiB JPEG, base64 encoded: 6144 * 4 / 3
/// An EDGE-ish cell: 128 kbit/s of goodput is 16000 bytes a second, and half a
/// second of round trip. Both are optimistic for a genuinely bad connection.
const int bytesPerSecond = 16000;
const int roundTripMs = 500;
int pageBytes({required int perItem, required int items}) => perItem * items;
/// Transfer time plus one round trip PER REQUEST. The latency term is the half
/// that gets forgotten, and on this connection it dominates.
int millisFor({required int bytes, required int requests}) =>
requests * roundTripMs + (bytes * 1000) ~/ bytesPerSecond;
void main() {
const perPage = 20;
final inlineBytes = pageBytes(perItem: textFields + inlineThumb, items: perPage);
final urlOnlyBytes = pageBytes(perItem: textFields, items: perPage);
final placeholderBytes =
pageBytes(perItem: textFields + blurPlaceholder, items: perPage);
final oneRequest = millisFor(bytes: placeholderBytes, requests: 1);
final twentyRequests = millisFor(bytes: placeholderBytes, requests: perPage);
final latencyOnly = perPage * roundTripMs;
final transferOnly = (placeholderBytes * 1000) ~/ bytesPerSecond;
print('inline thumbnails: $inlineBytes bytes, '
'${millisFor(bytes: inlineBytes, requests: 1)} ms');
print('urls only: $urlOnlyBytes bytes, '
'${millisFor(bytes: urlOnlyBytes, requests: 1)} ms');
print('urls + blurhash: $placeholderBytes bytes, $oneRequest ms');
print('twenty requests instead of one: $twentyRequests ms');
test('a page of twenty text-only items is 9600 bytes', urlOnlyBytes, 9600);
test('inlining a base64 thumbnail makes the same page 173,440 bytes',
inlineBytes, 173440);
test('which is 11.34 seconds before the first item can paint',
millisFor(bytes: inlineBytes, requests: 1), 11340);
test('urls plus a 32 byte placeholder is 10,240 bytes, so 1.14 seconds',
[placeholderBytes, oneRequest], [10240, 1140]);
test('so the inline version ships nearly seventeen times the bytes',
close(inlineBytes / placeholderBytes, 16.94, 0.01), true);
test('and twenty single-item requests cost 10.64 seconds instead of 1.14',
[oneRequest, twentyRequests], [1140, 10640]);
test('because on this connection the waiting is round trips, not bytes',
latencyOnly ~/ transferOnly, 15);
}
What it printed, when the build ran it
inline thumbnails: 173440 bytes, 11340 ms urls only: 9600 bytes, 1100 ms urls + blurhash: 10240 bytes, 1140 ms twenty requests instead of one: 10640 ms ok a page of twenty text-only items is 9600 bytes ok inlining a base64 thumbnail makes the same page 173,440 bytes ok which is 11.34 seconds before the first item can paint ok urls plus a 32 byte placeholder is 10,240 bytes, so 1.14 seconds ok so the inline version ships nearly seventeen times the bytes ok and twenty single-item requests cost 10.64 seconds instead of 1.14 ok because on this connection the waiting is round trips, not bytes
What that arithmetic buys you is a specific design rather than a preference. Return item JSON with image URLs and a thirty-two byte placeholder string, so the list can lay out and paint immediately with a blurred colour block in the right aspect ratio; fetch the images separately, lazily, at the size the widget actually needs, on a cache that survives a restart. The placeholder costs 640 bytes a page and removes every layout jump. And ask the server for the whole page in one request — twenty round trips is ten seconds of nothing happening, which no amount of client cleverness recovers.
Phase 4 — what you would measure
A design with no instrumentation is a design you cannot defend after it ships. Four numbers, each with a source and a target.
- Time to first item painted, split by cold cache and warm cache. This is the number the user experiences, and the two cases have completely different fixes. Target as a percentile, per lesson 36 — p90 under a second warm is a reasonable opening bid.
- Cache hit rate on feed opens. If it is low, the TTL or the eviction policy is wrong and every open is paying full price. This is the number that tells you whether the offline work is doing anything at all.
- Sync failure rate, and outbox age. Not just "did the request fail" but how long the oldest queued write has been waiting. A poison message blocking a queue is invisible in an error rate and obvious in an age.
- The p99, on the cheapest supported device. Not the mean, and not your phone. Lesson 36's panel is the argument for this: the mean passed a budget the tail was nowhere near, and the tail is where the growth markets are.
And one negative number worth volunteering: duplicate impressions. If you ship the offset version and log post ids as they are rendered, that metric goes non-zero and nobody knows why. Being able to say what you would have watched to catch your own bug is a strong signal.
Phase 4, continued — what ships first, and what waits
A senior answer ends with a rollout, not a diagram. This is the part most candidates never reach, and it is disproportionately what the notes remember.
- First, read-only offline. Cursor pagination, local store as the source of truth, stale-while-revalidate, placeholders and lazy images. This is most of the value — the feed opens instantly and works on the underground — and it carries almost no correctness risk, because nothing is being written.
- Second, offline posting, behind a flag. Persisted outbox, device ids, pending markers in the UI. Flag defaulted off, then one per cent, then ten. The flag is not ceremony: if the outbox has a bug, a flag flip is minutes and a store release is a fortnight.
- Deferred, and say so explicitly. Offline edits and deletes, because they cost a conflict policy per field. Background sync, because it needs platform work on both operating systems and its own failure modes. Prefetching the next page, because it is an optimisation and I do not have the cache hit rate yet. Ranked feed support, until the server has an opinion about cursors.
- The kill switch. One remote flag that turns the local store back into a pass-through cache. If sync corrupts data in the field, that is the difference between a bad afternoon and a bad fortnight.
Deferring things is not hedging, it is the senior move in the answer. "Edits sync later, because that is where conflict resolution lives and I would rather ship the ninety per cent that has no conflicts" tells the interviewer you can sequence work by risk. A candidate who designs the whole thing at once has told them the opposite.
The template, reusable on any prompt
- Clarify. Two minutes. Scale, the vague adjective as a number, correctness, constraints. State your assumptions out loud and move on.
- Draw the seams. Layers, who owns the data, one source of truth. Say which option you take at each seam and what it costs.
- Go deep on one thing, with numbers. Pick the part where you can be concrete — pagination, payload size, a frame budget — and be concrete. One demonstrated mechanism outweighs a complete but shallow tour.
- Say what you would measure. Three or four numbers, each with a source and a target.
- Sequence it. What ships first, what is behind a flag, what is deferred and why, and how you roll back.
- Name the biggest risk you are carrying. One sentence. "The thing most likely to be wrong here is my assumption that the feed is chronological, and if it is not, the cursor becomes a server-side token and the caching gets harder."
Two failure modes to avoid on the way through. Drawing boxes for twenty
minutes without a single number in them: it looks like a design and contains no decisions.
And the opposite — going straight to a favourite technique before anyone has agreed what the
problem is, which is the same mistake as reaching for RepaintBoundary before
looking at the frame chart. The order in this template exists because each step constrains
the next.
38 Defending a tradeoff out loud
A tradeoff you can only describe is not a tradeoff you have made. The senior signal is not which option you picked — it is whether you can name what you gave up for it, and what would make you pick the other one.
The five parts, in order
Every defensible answer has the same shape. Learn the shape rather than a script, because the interviewer will ask about something you have never seen.
- Constraint — the fact that forced a decision at all. A team size, a deadline, a device fleet, a network, a compliance rule, a number from production. Without one, every option is equally fine and the question is a taste test.
- Options — the two or three you genuinely considered. Two is enough. Five is a reading list.
- Axis — the single thing you optimised for. Latency. Time to first commit for a new joiner. Bytes on the wire. Blast radius of a bad release. Name one; if you claim you optimised for three you have not chosen anything.
- Cost — what got worse. This is the part that separates a decision from a preference, and it is the part almost everybody skips.
- Signal — the observation that would send you back. "If the p95 for that endpoint went above 400ms" is a signal. "If it became a problem" is not.
Most candidates deliver the first two and stop. They list the options accurately, pick one, and wait. From the other side of the table that reads as a summary of a blog post rather than an account of something they have lived with.
The tell
Here is the diagnostic an interviewer is running, whether or not they would put it this way: can this person name a cost of their own choice, unprompted? Somebody who cannot has not made a decision, they have expressed a preference, and preferences do not survive contact with a constraint they did not anticipate.
Same question, two answers. Weak: "I would use a batch endpoint. N+1 is an anti-pattern — you are making fifty round trips when one would do, and it hammers the backend. So: one request, one response, much faster." Everything in that is true. It also contains no constraint, no number, and nothing the candidate had to weigh, so it tells the interviewer only that they have read the same articles as everyone else.
Strong: "The feed showed fifty rows and each row needed its detail. On the network our users were actually on, a round trip was about 120ms, so the serial version was six seconds to a complete screen. Six parallel connections got that to nine waves, so just over a second — most of the win, no backend change, shipped in a day. We went to a batch endpoint afterwards for the last factor of nine, and it cost us a new endpoint to version, a partial-failure decision nobody wanted to make — one row of fifty fails, does the whole call fail? — and the loss of progressive rendering, because a 20kB response arrives all at once or not at all. If the backend team had been mid-migration I would have stayed on parallel n+1 and not felt bad about it."
That answer has a constraint, an axis, three named costs and an explicit condition for choosing differently. It is also almost entirely arithmetic, which is why it is worth doing the arithmetic.
The numbers behind it
/// A way of fetching the same fifty rows, priced in round trips and bytes.
class Plan {
const Plan(this.name, this.roundTrips, this.bytes);
final String name;
final int roundTrips;
final int bytes;
/// Latency is round trips, not bytes. That is the whole argument.
int get ms => roundTrips * rtt;
}
const rtt = 120; // ms for one request and its reply, a fair mobile figure
const items = 50; // rows on the screen
const itemBytes = 400; // the useful payload for one row
const perRequestBytes = 200; // headers and TLS records, paid once per request
/// n requests over a fixed number of connections do not overlap for free: they go
/// in waves, and a partly full wave still costs a whole round trip.
int waves(int n, int connections) => (n / connections).ceil();
Plan nPlusOne(int connections) => Plan(
'n+1 over $connections connection(s)',
waves(items, connections),
items * (itemBytes + perRequestBytes),
);
const batched = Plan('one batched request', 1, items * itemBytes + perRequestBytes);
void main() {
final serial = nPlusOne(1);
final parallel = nPlusOne(6);
for (final plan in [serial, parallel, batched]) {
print('${plan.name}: ${plan.roundTrips} round trip(s), ${plan.ms} ms, ${plan.bytes} bytes');
}
test('fifty serial round trips cost six seconds', serial.ms, 6000);
test('one batched round trip costs a hundred and twenty milliseconds', batched.ms, 120);
test('six connections need nine waves, because eight and a third is not a thing',
waves(items, 6), 9);
test('so the same n+1 parallelised still costs 1080ms', parallel.ms, 1080);
test('parallelism alone is worth five and a half times',
close(serial.ms / parallel.ms, 5.5556, 0.001), true);
test('batching is fifty times the serial plan and nine times the parallel one',
[serial.ms ~/ batched.ms, parallel.ms ~/ batched.ms], [50, 9]);
test('and it drops 9800 bytes of per-request overhead',
serial.bytes - batched.bytes, 9800);
}
What it printed, when the build ran it
n+1 over 1 connection(s): 50 round trip(s), 6000 ms, 30000 bytes n+1 over 6 connection(s): 9 round trip(s), 1080 ms, 30000 bytes one batched request: 1 round trip(s), 120 ms, 20200 bytes ok fifty serial round trips cost six seconds ok one batched round trip costs a hundred and twenty milliseconds ok six connections need nine waves, because eight and a third is not a thing ok so the same n+1 parallelised still costs 1080ms ok parallelism alone is worth five and a half times ok batching is fifty times the serial plan and nine times the parallel one ok and it drops 9800 bytes of per-request overhead
Read the middle row, because it is the one that changes the conversation. Parallelism is a client-side change you can make this afternoon, and it recovers 82 per cent of the available latency — 6000ms to 1080ms out of a best case of 120ms. Batching recovers the rest and needs a backend. A candidate who only knows "n+1 is bad" cannot tell you which of those to do first, and picking the cheap 82 per cent while the expensive change is scheduled is exactly the judgement being tested.
The costs of batching, none of which the anti-pattern framing mentions. A batch endpoint is an endpoint: it needs designing, versioning, authorising and rate limiting. Partial failure becomes a design decision rather than something the client already handles row-by-row. Caching gets worse, because fifty individually cacheable responses collapse into one composite that is stale as soon as any part of it is. And nothing renders until the whole body arrives, so the screen goes from "filling in" to "blank, then complete" — which users sometimes prefer and sometimes hate, and either way is a product decision, not yours alone.
Say the ratio, not the adjective. "Six seconds to just over one, and 120ms if we batch" is a sentence that ends an argument. "N+1 is slow" starts one, because the other person can hold the opposite opinion at exactly the same cost.
A second one, priced: how wrong is the screen allowed to be?
Cache time-to-live is the tradeoff most often chosen by copying a number out of another project. It has two units, and people quote only the first: requests saved, and how out of date the screen is permitted to be. Below, one user's hour is replayed against three TTLs so both units come out at once.
/// What a time-to-live actually buys and actually costs, replayed against one
/// user's session rather than argued about.
class Ttl {
const Ttl(this.minutes);
final int minutes;
/// The requests made, the reads served from cache, and the worst age any read
/// was served at - which is the number the product owner cares about.
({int requests, int hits, int worstAge}) replay(List<int> opens) {
int? fetchedAt;
var requests = 0;
var hits = 0;
var worstAge = 0;
for (final t in opens) {
final last = fetchedAt;
if (last == null || t - last >= minutes) {
requests++;
fetchedAt = t;
} else {
hits++;
if (t - last > worstAge) worstAge = t - last;
}
}
return (requests: requests, hits: hits, worstAge: worstAge);
}
}
const dau = 50000;
void main() {
// One user, one hour, opening the feed every two minutes. Thirty reads.
final opens = [for (var t = 0; t <= 58; t += 2) t];
final oneMinute = const Ttl(1).replay(opens);
final fiveMinutes = const Ttl(5).replay(opens);
final quarterHour = const Ttl(15).replay(opens);
print('opens in the session: ${opens.length}');
print('ttl 1m: ${oneMinute.requests} requests, worst age ${oneMinute.worstAge}m');
print('ttl 5m: ${fiveMinutes.requests} requests, worst age ${fiveMinutes.worstAge}m');
print('ttl 15m: ${quarterHour.requests} requests, worst age ${quarterHour.worstAge}m');
final saved = (30 - fiveMinutes.requests) * dau;
print('requests not made per day at $dau users: $saved');
test('the session is thirty reads', opens.length, 30);
test('a five minute ttl turns those thirty reads into ten requests',
fiveMinutes.requests, 10);
test('so twenty of the thirty were served from cache', fiveMinutes.hits, 20);
test('the worst a read was served at is four minutes, not the five in the config',
fiveMinutes.worstAge, 4);
test('a one minute ttl is no cache at all at this read rate',
[oneMinute.requests, oneMinute.hits], [30, 0]);
test('fifteen minutes gets to four requests, and fourteen minutes of wrong',
[quarterHour.requests, quarterHour.worstAge], [4, 14]);
test('at fifty thousand users the five minute ttl is a million requests a day not made',
saved, 1000000);
}
What it printed, when the build ran it
opens in the session: 30 ttl 1m: 30 requests, worst age 0m ttl 5m: 10 requests, worst age 4m ttl 15m: 4 requests, worst age 14m requests not made per day at 50000 users: 1000000 ok the session is thirty reads ok a five minute ttl turns those thirty reads into ten requests ok so twenty of the thirty were served from cache ok the worst a read was served at is four minutes, not the five in the config ok a one minute ttl is no cache at all at this read rate ok fifteen minutes gets to four requests, and fourteen minutes of wrong ok at fifty thousand users the five minute ttl is a million requests a day not made
Three things in there are worth saying in an interview. The one minute TTL saves nothing at all for this access pattern, because it expires faster than the user comes back — a cache whose TTL is shorter than the gap between reads is a cache in name only, and that is a measurement, not a guess. The five minute TTL is a two-thirds hit rate, and a million requests a day at fifty thousand users. And the honest staleness figure is not the TTL: it is the worst age actually served, four minutes here, which is the number to put in front of whoever owns the product.
The refinement that gets marked well. Staleness is a per-field tolerance, not a per-screen one. On an account screen, the transaction list can be five minutes old and nobody is harmed; the balance cannot be five seconds old without generating a support ticket. So the answer is not one TTL but two, and the interesting follow-up is how you keep two ages on one screen from looking inconsistent to the user. That is lesson 27's single source of truth doing real work.
The ones you will be asked, and should arrive with an answer to
Each of these has a constraint that decides it. Have one sentence of "I chose X because Y, and it cost me Z" ready for each, from something you actually shipped.
- Codegen versus hand-written (lesson 29) — axis: correctness of the boring code, and how fast a new joiner can add a model. Cost: build time, a generated-file story in code review, and a version conflict you did not choose. The signal: a cold build long enough that people stop running it.
- Offline-first versus online-only (lesson 28) — axis: whether the app is usable on a train. Cost: you have accepted conflict resolution, and conflict resolution is a product decision disguised as an engineering one. The signal: if the data is read-only per user, offline-first is nearly free and you should just do it.
- One large Bloc versus several small ones (lesson 21) — axis: how much of a flow you can read in one file against how much rebuilds when one field changes. Cost of one large: coarse rebuilds and a file nobody wants to touch. Cost of many small: cross-Bloc coordination, which is where the ordering bugs live.
- Result types versus exceptions (lesson 26) — axis: whether a forgotten failure path is a compile error. Cost: ceremony at every call site, and two error styles in the codebase during the migration you will not finish. The signal: exceptions are fine for genuinely exceptional things; a network being down is not exceptional.
- Monorepo versus separate packages — axis: atomic change across boundaries against independent release cadence. One repo means a refactor touching six packages is one reviewable commit; separate packages mean a team can ship without waiting, and pay in version matrices and a day spent on a diamond dependency. The signal: how often two packages need to change together. Frequently, and you have paid for a boundary that is not real.
- Third-party plugin versus your own channel (lesson 39) — axis: time to working feature against control of the failure modes. Cost of the plugin: someone else's release schedule on your critical path, and their choice of what to expose. Cost of your own: two more languages in the repo and a Kotlin and Swift bug surface with nobody else finding the bugs first. The signal: read the plugin's issue tracker before you decide — last release date and open-issue age tell you more than the star count.
Disagreeing well
They will push back. Some of the time it is because you are wrong; most of the time it is to find out what you do when contradicted, because that is what you will be like in a design review. There are two ways to fail it. Folding immediately — "oh, yes, you are right, I would do it your way" — says your reasoning was decoration. Digging in with more of the same assertion says you cannot be moved by evidence, which is worse.
The move that works is to name the condition under which they are right. "You would be right if the write volume were high enough that the batch endpoint's cache invalidation became the bottleneck — at our volume it was not, but if this were the payments service I would go the other way." Now you are both looking at the same axis instead of trading preferences, and you have shown the thing they were actually checking for: that your position has a boundary and you know where it is.
Two sentences worth keeping. "I do not know, and here is how I would find out" is a strong answer when it is followed by an actual method — the metric, where you would read it, and what number would settle it. And "we got that wrong, this is what it cost us, and this is what I changed" is the single most convincing thing a senior candidate can say, because it is the only claim in the whole interview that cannot be made by someone who has not done the job.
39 Platform channels and native interop
A platform channel is an asynchronous, serialised message pass between your Dart isolate and the platform's main thread. Every word in that sentence has a consequence, and naming the consequences is most of what an interviewer is listening for.
What is actually happening
There is no shared memory and no function call. Your arguments are encoded to bytes by a
codec, posted onto a queue, picked up on the platform's main thread, decoded into Java, Kotlin,
Objective-C or Swift objects, handled, and the reply makes the same trip back. Underneath
every channel is one BinaryMessenger moving ByteData; the channel
classes are conveniences on top of it.
- Asynchronous — every call returns a
Future. There is no synchronous variant, by design, because the reply has to cross a thread boundary. If you need synchronous, you need FFI, further down. - Serialised — arguments are copied, not shared. A large
Uint8Listcosts real time and real memory on both sides, twice. - The platform's main thread — the handler runs where the UI toolkit
lives. Slow work there is an ANR on Android and a beachball on iOS, and no amount of
asyncon the Dart side changes that. - Ordered per channel — messages on one channel arrive in the order sent. Two channels have no ordering relationship at all, which is a real source of "it works on my phone" bugs when two features coordinate through the host.
Three channel types, and the choice is usually obvious once you say the shape of the data out loud:
MethodChannel— request and reply, named method, one answer. "What is the battery level", "open this file picker". The default, and the right one about ninety per cent of the time.EventChannel— the host pushes a stream and you listen. Sensor readings, connectivity changes, download progress, scan results. It is aMethodChannelunderneath, usinglistenandcancel, which is why forgetting to cancel the subscription leaks a native listener as well as a Dart one. Lesson 34's rule applies on both sides of the boundary.BasicMessageChannel— bare messages, no method name, and a codec you pick. Right when the payload is the message and there is nothing to dispatch on: a single video frame, a protobuf blob, one continuous flow in both directions. Rare in application code, common inside plugins.
The codec decides what can cross
StandardMessageCodec handles null, bool, int,
double, String, Uint8List, Int32List,
Int64List, Float64List, and List and Map
of any of those, recursively. That is the whole list. Your classes are not on it, which is why
every plugin you have read sends a Map and rebuilds an object on the far side.
import 'dart:typed_data';
/// Exactly what StandardMessageCodec will put on the wire. Nothing else has a
/// representation, so nothing else can cross - and the codec finds that out at run
/// time, in release, on a user's phone.
bool encodable(Object? value) {
if (value == null) return true;
if (value is bool || value is int || value is double || value is String) return true;
if (value is Uint8List || value is Int32List || value is Int64List || value is Float64List) {
return true;
}
// Order matters: the typed lists above are all Lists too.
if (value is List) return value.every(encodable);
if (value is Map) return value.keys.every(encodable) && value.values.every(encodable);
return false;
}
/// A domain object. None of it is transportable, and that is normal.
class Money {
const Money(this.minor, this.currency);
final int minor;
final String currency;
/// The only shape that crosses. The host rebuilds its own type from this.
Map<String, Object?> toWire() => {'minor': minor, 'currency': currency};
}
/// Which box an int is written into by the codec.
String width(int v) => v >= -2147483648 && v <= 2147483647 ? '32-bit' : '64-bit';
void main() {
const money = Money(1999, 'GBP');
print('a Money: ${encodable(money)}');
print('its wire form: ${money.toWire()} -> ${encodable(money.toWire())}');
print('2147483647 goes as ${width(2147483647)}, 2147483648 as ${width(2147483648)}');
print('9007199254740993 is exact on the VM: ${9007199254740993 - 9007199254740992 == 1}');
test('null, bools, ints, doubles and Strings all cross as themselves', [
encodable(null),
encodable(true),
encodable(7),
encodable(1.5),
encodable('ada'),
], [true, true, true, true, true]);
test('and the four typed lists the codec knows by name', [
encodable(Uint8List(1)),
encodable(Int32List(1)),
encodable(Int64List(1)),
encodable(Float64List(1)),
], [true, true, true, true]);
test('a Map of Lists of those is fine, checked all the way down',
encodable({'rows': [1, 2.0, 'three', null], 'blob': Uint8List(4)}), true);
test('a Set is not: the codec has no representation for one',
encodable({'a', 'b'}), false);
test('nor is an arbitrary class, which is why you send its Map instead',
[encodable(money), encodable(money.toWire())], [false, true]);
test('one unencodable leaf makes the whole argument unsendable',
encodable({'amount': money, 'note': 'fine on its own'}), false);
test('ints up to 2^31-1 cross as 32 bits, larger ones as 64',
[width(2147483647), width(2147483648)], ['32-bit', '64-bit']);
}
What it printed, when the build ran it
a Money: false
its wire form: {minor: 1999, currency: GBP} -> true
2147483647 goes as 32-bit, 2147483648 as 64-bit
9007199254740993 is exact on the VM: true
ok null, bools, ints, doubles and Strings all cross as themselves
ok and the four typed lists the codec knows by name
ok a Map of Lists of those is fine, checked all the way down
ok a Set is not: the codec has no representation for one
ok nor is an arbitrary class, which is why you send its Map instead
ok one unencodable leaf makes the whole argument unsendable
ok ints up to 2^31-1 cross as 32 bits, larger ones as 64
Two details from that panel worth having ready. The Set case is the one that
bites in practice, because a Set<String> of selected ids looks obviously
sendable and is not; .toList() fixes it and the fix has to be remembered at every
call site. And notice the failure is not a compile error. A Map holding one
unencodable value throws when you call the channel, which means the bug ships.
On integers: the codec writes small values in a 32-bit slot and anything outside
-2^31 .. 2^31-1 in a 64-bit one, so a Dart int arrives on the host as
an Integer or a Long depending on its value, not its type.
Kotlin code written as call.argument<Int>("id") works in testing with small
ids and throws on a real snowflake id. Web changes the story again: on a JavaScript target a
Dart int is a double, exact only to 2^53, so 9007199254740993 — which
the VM handles exactly, as the panel prints — is not representable at all, and there is no
Int64List. Identifiers that cross a channel are safest as strings.
Testing a channel with no native code at all
This is the panel to remember, because it answers the question candidates most often get wrong: "how do you test a plugin?" The channel is the seam. Substitute the host at the binary messenger and the entire Dart side becomes testable — including both failure modes, which is where the interesting behaviour is.
The three tests are the three cases, and the third is the one nobody writes. Note also that
this is a plain test, not testWidgets: testWidgets runs
inside a fake-async zone, so asynchronous work that needs real time has to be wrapped in
tester.runAsync — the escape hatch lesson 31 flagged. When there is no widget tree
to pump, skip the fake clock entirely and the awaits behave as they read. The mirror API for a
stream is setMockStreamHandler, which fakes an EventChannel the same
way.
What this proves about testing generally: a boundary you can substitute is a boundary you can test, and a channel is the cleanest substitutable boundary in a Flutter app. If a candidate says plugin code cannot be unit tested, they have not looked. What is not tested here is the Kotlin and the Swift, and saying so is part of the answer — that needs an instrumented test on each platform, and it is a different job.
The failure modes, named
MissingPluginExceptionversusPlatformException— completely different bugs. The first means nothing on the host side is listening for that method: a typo in the name, a plugin not registered, a method you forgot to add to thewhenblock, or an app that needs a full restart rather than a hot reload after a native change. The second means the host was reached, ran, and reported a failure with a code you chose. Treat the first as a wiring bug and the second as a runtime condition to handle.- Channel name collisions — the name is a single flat global string
namespace, shared by your app and every plugin in it. Two packages both using
"battery"means the last one registered wins and the other silently gets the wrong replies, which is a genuinely horrible afternoon. Namespace with a domain you control:"com.yourcompany.yourplugin/battery". - Blocking the platform main thread — the handler runs where the UI
toolkit lives. A synchronous file scan or database open there freezes the platform side, and
because your Dart is
awaiting, the app appears frozen too. Do the work on a background thread on the host and reply when it is finished, or attach the channel to a background task queue where the platform offers one. - Calling a channel per frame — each call is an encode, a queue hop, a
thread switch, a decode and the same again for the reply. The per-call cost is small but it
is not free, and it is not on your frame budget's terms: the reply arrives when the platform
thread gets to it, which may not be this frame. Per-frame channel traffic is a real jank
source and it shows up on the timeline exactly as lesson 33 describes. Cache the value, or
have the host push changes over an
EventChannelinstead of asking sixty times a second.
The one nobody mentions. Channels are per-isolate. A background isolate
does not get the default messenger for free — it needs
BackgroundIsolateBinaryMessenger.ensureInitialized with a token from the root
isolate. So "I will just call this plugin from my compute isolate" does not work by default,
and that is worth knowing before you architect a background sync around it.
Pigeon: making the typo a compile error
Everything above is stringly typed. invokeMethod('getLevel') against a host
that spells it getlevel compiles perfectly on both sides and fails as a
MissingPluginException on a user's device. The argument map has the same problem
one level down: rename a key on one side and the other side reads null.
Pigeon removes the strings. You write a schema in Dart — data classes and abstract APIs — and it generates the Dart calling code, the Kotlin or Java host interface and the Swift or Objective-C host protocol. The host side becomes an interface you must implement, so a method you have not written will not compile, and a renamed field breaks the build rather than the app. It is the same argument as lesson 26's Result types and lesson 29's codegen: move the failure from run time to compile time, and pay for it in a build step.
// pigeons/battery.dart
//
// A schema, not app code. It is never shipped and never imported by the app; it is
// input to "dart run pigeon --input pigeons/battery.dart". The generated files are
// what you import, on both sides.
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/src/battery.g.dart',
kotlinOut: 'android/src/main/kotlin/com/example/battery/Battery.g.kt',
swiftOut: 'ios/Classes/Battery.g.swift',
),
)
/// Becomes a Dart class, a Kotlin data class and a Swift struct. Rename a field
/// and every one of the three stops compiling, which is the entire point.
class BatteryReading {
late int percent;
late bool charging;
}
/// Dart calls the host. Generates a caller in Dart and an interface on the host
/// side that the host MUST implement - a missing method is a build failure, not a
/// MissingPluginException in production.
@HostApi()
abstract class BatteryApi {
BatteryReading read();
/// @async lets the host reply on a callback, so the platform main thread is free
/// while the work happens.
@async
BatteryReading readAfter(int delayMillis);
}
/// The other direction: the host calls Dart. Replaces the EventChannel pattern for
/// a fixed set of known events.
@FlutterApi()
abstract class BatteryEvents {
void onLevelChanged(BatteryReading reading);
}
Pigeon is a build-time generator that emits Kotlin and Swift, and it is not a dependency of this verification project. Running it here would produce source for toolchains this build does not have. The Dart panels above and the widget test are the parts that can be verified, and they are.
The cost, so you can name it: another generator in the build, generated files in the repo that reviewers have to learn to skip, and a hard stop when you need something the schema cannot express. Worth it once more than one person maintains the plugin, or once the API is more than a handful of methods. Not obviously worth it for a single call to read a battery level.
FFI, which is the other thing entirely
dart:ffi calls a C symbol directly. There is no codec, no queue and no thread
hop — and therefore no Future: the call is synchronous and it blocks the isolate
that made it. That is exactly right for a CPU-bound C library you already trust, an image codec
or a crypto primitive, especially from a background isolate where blocking harms nobody. It is
exactly wrong for anything that talks to the platform's own frameworks, because those want the
main thread and often want to reply later. A channel is asynchronous by construction; FFI makes
"do not block the UI" your problem, and you carry memory management with it.
// lib/src/fast_hash.dart
import 'dart:ffi';
import 'dart:io' show Platform;
import 'dart:typed_data';
// The C signature, twice: once in FFI types, once in Dart types. Getting either
// wrong is not a compile error - it is a corrupt stack at run time, which is a
// different class of bug from anything a channel can give you.
typedef _HashNative = Uint32 Function(Pointer<Uint8> data, IntPtr length);
typedef _HashDart = int Function(Pointer<Uint8> data, int length);
final DynamicLibrary _lib = Platform.isAndroid
? DynamicLibrary.open('libfasthash.so')
: DynamicLibrary.process();
final _HashDart _fastHash = _lib.lookupFunction<_HashNative, _HashDart>('fast_hash');
/// Synchronous. No await, and no channel. It returns before the next line runs,
/// and if the C takes 40ms then this isolate is gone for 40ms.
int fastHash(Uint8List bytes) {
// Dart memory is not C memory, so the bytes are copied into a native buffer.
final buffer = malloc<Uint8>(bytes.length);
try {
buffer.asTypedList(bytes.length).setAll(0, bytes);
return _fastHash(buffer, bytes.length);
} finally {
// No garbage collector out here. Miss this and you have a native leak that
// DevTools' Dart heap view will not show you.
malloc.free(buffer);
}
}
It needs a compiled libfasthash for this machine, a native build hook and package:ffi for malloc. None of those exist in this verification project, and a listing that pretends to run would be worse than one that says so.
The host sides, for reference
These are read rather than run, and the reason is worth stating plainly: this build has no
Android SDK and no Xcode, so there is no honest way to execute Kotlin or Swift here. What the
build can verify it does — the codec panel above was executed and analysed by the real
Dart SDK, and the channel test was run by a real flutter test. These two listings
are the only part of this lesson taken on trust, and they are the part that needs a device
anyway.
// android/src/main/kotlin/com/example/battery/BatteryPlugin.kt
package com.example.battery
import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class BatteryPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var context: Context
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// This string is a global namespace shared with every plugin in the app.
// "battery" would be asking for a collision.
channel = MethodChannel(binding.binaryMessenger, "com.example.battery/battery")
channel.setMethodCallHandler(this)
context = binding.applicationContext
}
// Runs on the platform main thread. Anything slow in here is an ANR, and the
// Dart side just sees a Future that never completes.
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"getLevel" -> {
val unit = call.argument<String>("unit")
if (unit != "percent") {
// Becomes PlatformException(code: 'BAD_UNIT', ...) in Dart. The three
// arguments are exactly code, message and details.
result.error("BAD_UNIT", "only percent is supported, got $unit", null)
return
}
val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
if (level < 0) {
result.error("NO_BATTERY", "no battery on this device", null)
} else {
// Exactly once, and on the main thread. Twice throws; never leaves the
// Dart Future hanging forever.
result.success(level)
}
}
// Anything not named here becomes MissingPluginException in Dart. Note the
// difference: this is a wiring bug, not a runtime failure.
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
Kotlin, and this build has no Android SDK or Gradle toolchain, so it cannot be compiled here. Everything in this course that can be executed is executed; this cannot, and says so.
// ios/Classes/BatteryPlugin.swift
import Flutter
import UIKit
public class BatteryPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
// The same name as the Kotlin side and the same name as the Dart side. Three
// copies of one string is precisely the problem Pigeon removes.
let channel = FlutterMethodChannel(
name: "com.example.battery/battery",
binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(BatteryPlugin(), channel: channel)
}
// Called on the main thread. Same rule as Android: get off it before doing
// anything slow, and call result exactly once.
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getLevel":
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel
if level < 0 {
// FlutterError's three fields land on PlatformException.code, .message
// and .details.
result(FlutterError(
code: "NO_BATTERY",
message: "no battery on this device",
details: nil))
} else {
// batteryLevel is 0.0 to 1.0, so this crosses as an int percentage - and
// the Dart side asked for an int, so sending a Double here would throw a
// type error over there rather than here.
result(Int(level * 100))
}
default:
// Not an error. This sentinel is what becomes MissingPluginException.
result(FlutterMethodNotImplemented)
}
}
}
Swift, and this build has no Xcode or iOS SDK, so it cannot be compiled here. It is included because the code and the Dart contract have to be read together, and because the FlutterError to PlatformException mapping is the thing people get wrong.
What a senior interview is probing with this topic, and it is rarely the API. Do you know the call is asynchronous and serialised, and can you say what that costs? Do you know the difference between the two exceptions, because that difference is the difference between a deploy bug and a device condition? Can you test the Dart side without a device — and do you know that testing the Dart side is not testing the plugin? And would you write your own channel at all, or reach for the plugin and read its issue tracker first? Lesson 38's frame applies here too: the constraint, the axis, and the cost you accepted.