Interview guides · BAFTA
BAFTA Software Engineer Interview Questions & Guide 2026
Take-home style practice for BAFTA software engineer candidates: a small service, a fixed budget, a written tradeoff.
The 60-second brief
As a Software Engineer at BAFTA, you join a mission-driven organization dedicated to championing the art forms of the moving image. This role is not merely about writing code; it is about building the digital infrastructure that connects audiences, industry professionals, and creative talent to the world-class content that BAFTA curates and celebrates.
The loop does not sample the job evenly, and arguing about that in the room costs you. Daily work is mostly incremental change inside code someone else wrote, while the loop samples narrow slices of it; prepare for the slices and save the realism argument for your questions at the end.
Focus areas
- Design a bitrate ladder under stated constraints
- Sessionise at-least-once heartbeats into exactly-once plays
- Enforce entitlement at the licence boundary
Overview
You will contribute to high-impact projects, such as optimizing web video streaming platforms and enhancing the digital tools that support the organization’s prestigious awards and educational initiatives. The work is technically challenging, requiring a balance between performance, scalability, and the unique needs of a cultural institution. You can expect a collaborative environment where your technical decisions directly influence the user experience for a global audience.
The BAFTA interview process
BAFTA candidates report 2 rounds · ≈ 2-4 weeks. The stages below are what candidates describe, not a published process.
Application Review
The title covers product work, platform work, infrastructure, mobile and frontend, and those are different jobs with different loops behind them. A screening call is the cheapest place to find out which one the seat is, and asking reads as experienced rather than fussy. The questions that separate them: what the team is on call for, what the last three projects were, and whether any round happens inside an existing repository instead of a blank file. Then say which of that you have done and which you have not. Claiming the whole posting is the fastest way to be found out one round later.
What to demonstrate
- Whether you can locate your experience inside one flavour of the role honestly instead of claiming the entire requirements list
- Whether you name what you have not done, which an experienced screener reads as a level signal and can plan the loop around
- Whether what you want next matches what the seat is: someone who wants greenfield work landing on a team that mostly operates an existing system is a hire that leaves within the year
How to prepare
- Mark every line of the posting as done, adjacent or new, and write one sentence for each adjacent line naming the closest thing you actually built
- Split your last two years into rough percentages across feature work, operating and debugging live systems, and design or review, so a question about scope gets numbers rather than adjectives
- Bring three questions that discriminate between seats: what the team is paged for, how much of the work is changing existing code versus standing up something new, and what shipped in the last quarter
In-Person Assessment
The same problem is scored by two different mechanisms depending on the format, and preparing for one does not cover the other. With a person watching, partial progress is visible and a hint is a correction you can absorb; silence is the expensive failure, because nobody can read a half-written function. With an automated grader there is no partial credit for what you were about to do, nobody to ask, and the worked examples in the prompt are the entire specification. Read them as a contract, down to whether an empty result should be an empty list or no output at all.
What to demonstrate
- In a live session, whether your commentary tracks what your hands are doing, and whether a hint redirects you or gets defended against
- In an automated one, whether you cover the cases the examples do not show, since the hidden cases are where the score moves
- Whether you manage the clock on purpose: abandoning an approach that is not converging while there is still time to write something simpler that finishes
How to prepare
- Have someone hand you a problem and feed you one deliberately wrong hint. Practise testing it against a concrete case instead of accepting or rejecting it on authority.
- Do one timed run a week in a plain browser editor with autocomplete, linting and your own snippets switched off, which is closer to what these environments give you
- For the automated format, write the harness before the solution: a main that feeds the worked examples plus an empty and a single-element case and prints expected against actual, so a wrong submission is caught by you first
Where candidates lose points
Treating the manifest as the authorisation boundary.
Checking entitlement when the manifest is requested feels natural, because that is where the session visibly begins. But the manifest is a cacheable document and the segment URLs inside it carry no identity, so anyone who obtains it plays until the delivery token expires, and a subscriber who cancels mid-title finishes the title. Authorisation has to sit at the licence request, the only thing on the path that is per-user and uncacheable by construction.
Including the delivery token, session id or any per-user parameter in the CDN cache key.
It looks like the conservative choice, and it behaves perfectly in staging where three people are watching. In production every viewer becomes a distinct cache object, hit ratio collapses toward zero, and the origin absorbs the entire read load at peak - a cost and availability event rather than a slow page. The correct shape is for the edge to verify the token's signature and then strip it from the key, which means the token has to be verifiable at the edge without a callback to any origin service.
Optimising an axis nobody named
Ask which resource is actually scarce here: wall-clock latency, throughput, memory footprint, cost per request, or engineering time. Shaving a constant factor off an in-memory step is wasted effort when the same function makes a blocking remote call inside the loop.
A queue or buffer with no bound
Every producer-consumer boundary needs a capacity and a policy for reaching it: block the producer, shed load, or drop the oldest entry. Unbounded buffering converts a temporary slowdown into memory exhaustion and hides the backpressure signal that would have revealed the consumer was falling behind.
Questions & practice
Rank delivery groups by rebuffer ratio without ranking on noise
Medium · top-k · heap · aggregation
A completed hour of telemetry yields roughly 10^6 groups keyed (cdn_pop, device_class, isp), each arriving as an unordered stream of per-minute partials carrying qualified_starts and rebuffered_starts. On-call needs the 50 worst groups by rebuffer ratio, excluding groups too small to mean anything: a group with 3 starts and 1 rebuffer reads as 33%. Return the top 50 with their ratios and sample counts. Target something better than a full sort, and state both time and space.
Approach
- Two phases, forced by the fact that the ranking key is a ratio that is only final once every partial for a group has landed. Phase one is a hash aggregation of partials into per-group counters, O(n) time and O(g) space for g groups.
- Phase two selects with a size-50 min-heap keyed on the ratio: O(g log k) time and O(k) space, against O(g log g) for a sort. At g = 10^6 and k = 50 that is about 6 comparisons per group rather than 20. Quickselect is O(g) but needs every group resident and mutable, so the heap wins when phase one streams.
- Apply the minimum-sample floor before the heap, never after. At a floor of 200 qualified starts the standard error of the ratio is sqrt(p(1-p)/n), about 1.5 percentage points at p = 5%, so the ordering carries information. Filtering after the heap leaves it full of three-sample groups and returns fewer than 50 usable rows.
- Offer the alternative that removes the arbitrary cutoff: rank on the lower bound of a Wilson score interval, which shrinks small groups toward the population mean automatically. The cost is a heavier comparison per group and a figure that is no longer the raw ratio people expect on the dashboard.
- If g does not fit in memory, partition by hash of the group key, take a local top-50 per shard and merge. Top-k decomposes this way only because each group's aggregate is complete within its shard, which the hash partition guarantees.
Follow-up
- Make it a rolling one-hour top-50 recomputed every minute. What changes when groups must also be evicted as old minutes leave the window?
- Two groups tie on ratio but differ 100x in volume. Which do you page on, and what does that argue about ranking on count-weighted impact instead of on ratio?
Detect overlapping availability windows including perpetual null end dates
Easy · intervals · sweep line · null semantics
availability_window holds (id, content_version_id, territory_code, licence_type, starts_at, ends_at NULL, max_resolution_cap NULL, status). Two active rows sharing (content_version_id, territory_code, licence_type) whose intervals intersect are a data bug, because serve-time resolution then picks one arbitrarily. ends_at NULL means perpetual. Intervals are half-open: a window ending at exactly the instant the next begins is not an overlap. Given up to 5 x 10^6 active rows, return every conflicting pair grouped by triple. Target O(n log n), and say what you do when one triple holds thousands of rows.
Approach
- Group by the triple with a hash map, O(n) expected. Groups are small in practice (single digits per content version and territory), so the sort cost is per group rather than over all 5 x 10^6 rows.
- Within a group sort by (starts_at asc, ends_at asc) and sweep with a running maximum end. Row r conflicts if r.starts_at < running_max_end, strictly less because the intervals are half-open. O(m log m) per group, O(n log n) overall.
- Represent NULL ends_at as a positive-infinity sentinel in the comparator, not as a coalesce to a far-future literal that a later row can legitimately exceed. One perpetual row conflicts with every later row in its triple, so report it once as a dominating row rather than enumerating m-1 pairs.
- Watch the output size, which is the real complexity risk: k conflicting pairs is O(m^2) within a pathological group, so the honest bound is O(n log n + k) and the API should stream or cap results rather than materialise them.
- Apply the same half-open convention to the serve-time range predicate, otherwise the audit and the runtime disagree about back-to-back windows and the audit becomes noise people learn to ignore.
Follow-up
- Two otherwise identical windows differ only in max_resolution_cap. Is that still a conflict, and how does the serve path choose deterministically?
- Run the check as a pre-commit guard on a single insert rather than as a batch audit. What does the existing index (content_version_id, territory_code, starts_at, ends_at) give you, and what is the query's cost?
Deduplicate at-least-once heartbeats into per-session watch time
Easy · idempotency · hash aggregation · streaming
You receive a batch of up to 10^7 heartbeat records, each (session_id UUID, sequence INT, played_ms BIGINT, position_ms BIGINT, wall_clock). Delivery is at-least-once and out of order: a client that crashed and resumed replays earlier sequences, and an offline device can deliver days of buffered records in one burst. played_ms is a monotonically non-decreasing counter reported by the client; position_ms is the playhead. The batch spans roughly 10^6 sessions. For each session emit accumulated watch time and the count of distinct sequences received. Target O(n) time, and state your memory bound and what you do when it is exceeded.
Approach
- Split the output by idempotency before writing any code: max(played_ms) is idempotent under duplicate delivery, so watch time needs no deduplication at all. The distinct-sequence count is not idempotent, and it is the only figure that forces you to remember what you have already seen.
- One hash map keyed by session_id holding {max_played_ms, seen-sequence structure}, filled in a single pass. O(n) expected time, O(g) space for g distinct sessions.
- Bound the memory with real numbers rather than an asymptote: sequences are dense per session and start at zero, so a compressed bitmap per session costs order 100-200 bytes, and 10^6 sessions is roughly 100-200 MB. A bounded-reorder window would be cheaper but is wrong here, because a bulk offline sync arrives arbitrarily late.
- If the map does not fit, partition on hash(session_id) mod P and process shards independently. Every aggregate is per-session, so the partition is exact and no reduce pass is needed to merge across shards.
- Emit max_played_ms as watch time and the bitmap cardinality as the distinct count; a gap between cardinality and max_seq + 1 is a loss signal worth emitting alongside, not a reason to reject the session.
Worked solution (20 min)
- Classify each required output as idempotent or not under duplicate delivery, and write down which ones therefore need stored state.
- Write the single-pass loop: map lookup, max update, bitmap set, no sorting anywhere.
- Compute the memory figure out loud: 10^6 sessions times the per-session structure, and compare it against the box you expect to run on.
- Describe the hash-partition fallback and argue why it needs no cross-shard merge.
- Construct a seek-heavy session by hand (forward 40 minutes, backward 10, forward 5) and show that the maximum is unchanged while a delta-based sum is not.
Expected result: A per-session map of {max played_ms, exact distinct-sequence count} produced in one O(n) pass with O(1) amortised work per record, a stated memory figure of order 100-200 MB at 10^6 sessions, and a hash-partition fallback that requires no second pass.
Verify it yourself
- Feeding the same batch twice produces byte-identical output.
- A synthetic session that seeks backwards ten times reports the same watch time as one that plays the same content straight through.
- Distinct-sequence count never exceeds max_seq + 1 for any session.
Follow-up
- A session's records arrive split across two batches a day apart. What has to persist for the per-session maximum to stay correct, and what does that cost per open session?
- played_ms decreases for 0.1% of sessions. Do you clamp, drop the record, or quarantine the session, and what does each choice do to payout counting downstream?
Put segment duration where alignment cannot drift
Medium · normalisation · check constraints · composite foreign key
content_version(id, title_id, duration_ms, encoder_build_id, packaging_status) and rendition(id, content_version_id, stream_kind ENUM('video','audio','subtitle'), width, height, frame_rate_milli_fps, segment_duration_ms, gop_size_frames, status). Every video rendition of a content version must have identical segment boundaries, and each segment must begin at an IDR frame, so gop_size_frames has to divide the segment length in frames. Both facts currently live per rendition and nothing stops a re-encoded rung from disagreeing. Restructure so the database rejects drift, and say what breaks at a frame rate of 24000/1001 with a 2,000 ms segment.
Approach
- Identify the functional dependency. For video rows,
segment_duration_msis determined bycontent_version_id, not byrendition_id. A dependency stored at the wrong grain is exactly what lets two rows disagree, and the invariant is presently a sentence in a comment rather than anything the database can refuse. - Move the authoritative value to the parent as
content_version.segment_frames. Ask whether the child copy earns its keep: the manifest build already loads the content_version row to resolve the URL, so the join costs nothing and the denormalised copy buys no read. If it is kept anyway for write-path convenience, guard it with a composite foreign key —UNIQUE (id, segment_duration_ms)on the parent andFOREIGN KEY (content_version_id, segment_duration_ms) REFERENCES content_version(id, segment_duration_ms)on the child. Under the default MATCH SIMPLE semantics a NULL in either child column satisfies the constraint, which is the escape hatch that lets audio and subtitle rows carry no video segment length. - Fix the representation before writing any CHECK. 24000/1001 is 23.976023976… and is not representable in integer thousandths, so
frame_rate_milli_fps = 23976is already a different number from the real rate and every derived value drifts from it. Store the exact rational asframe_rate_num INTEGER, frame_rate_den INTEGER— 24000 and 1001 — and store the segment length in frames rather than milliseconds. - With frames and an exact rational, both invariants become integer CHECKs that cannot round:
CHECK (segment_frames % gop_size_frames = 0)for IDR alignment, and the millisecond duration becomes derived —segment_frames * 1000 * den / num— rather than a second stored copy to keep in sync. - Work the stated case: at 24000/1001, 2,000 ms is 48000/1001 frames, about 47.952, so no segment of that target length contains a whole number of frames and no integer GOP size divides it. 96 frames is exactly 4.004 s and admits any GOP size dividing 96. The encoder has to round a millisecond target to whole frames, and that rounding is where two rungs encoded at different times end up on different boundaries.
- Accept what the schema still cannot catch. Rate control can relocate an IDR without changing any stored column, so add a post-packaging QC step that compares segment counts and first-sample timestamps across the video renditions of a version and fails the publish on a mismatch, and pin
encoder_build_idper rendition so a rung re-encoded on a newer build is a visible difference rather than an inference.
Follow-up
- One rung fails QC and is re-encoded alone. What do you verify before republishing the manifest, and what is the rollback if boundaries moved?
- Audio renditions legitimately use a different segment length from video. Which of your constraints still applies and which has to be weakened?
- How do you migrate the existing millisecond column to frames without a window in which neither constraint holds?
Make a monthly payout scan use an index at two billion rows
Medium · sargability · covering index · partitioning · query planning
qualified_play(id BIGSERIAL PK, session_id, content_version_id, rights_holder_id, account_id, territory_code, qualified_at TIMESTAMPTZ, status ENUM('pending','counted','reversed'), source ENUM('online','offline_sync'), dedupe_key UNIQUE, counted_in_period) holds about two billion rows, roughly 150 million per month, and is physically ordered by insert time. The monthly close runs SELECT rights_holder_id, count(*) FROM qualified_play WHERE date_trunc('month', qualified_at) = DATE '2026-08-01' AND status = 'counted' GROUP BY 1 and scans the entire table. Give three separate reasons it does, and the change you would make for each.
Approach
- Reason one is the expression. Wrapping the column in
date_truncmakes the predicate non-sargable, so no b-tree onqualified_atis usable and the planner also falls back to a default selectivity guess instead of the column's histogram. Rewrite to the half-open rangequalified_at >= DATE '2026-08-01' AND qualified_at < DATE '2026-09-01', which selects the same rows. An expression index is the alternative, but in PostgreSQLdate_trunc(text, timestamptz)is STABLE rather than IMMUTABLE because it depends on the session TimeZone, so it cannot be indexed directly — you would indexdate_trunc('month', qualified_at AT TIME ZONE 'UTC')and write the predicate in exactly that form. - Reason two is selectivity, and the fixed predicate does not remove it. One month is about 7.5% of the table; at roughly 50 rows per page the table is about 40 million pages, and 150 million rows scattered across it touch essentially every page. A plain index scan would pay random I/O for the whole table while a sequential scan reads it in order — the planner is right. Make the scan index-only instead:
(status, qualified_at, rights_holder_id)covers every column the query touches, so the plan reads index pages alone. That holds only while the visibility map is current, which makes it a dependency on autovacuum keeping up with continuous inserts. - Reason three is structural.
status = 'counted'is most of the table and is not selective by itself, but a partial indexWHERE status = 'counted'keeps reversed and pending rows out of the index entirely. At two billion rows the real fix is range partitioning byqualified_atmonth: the close reads one partition, the planner prunes the rest, and the partition boundary lines up withcounted_in_periodso a period close and a partition are the same object. - Name the BRIN trap before someone reaches for it. BRIN is the obvious cheap index for an append-ordered table and it is tight on the arrival timestamp — but
qualified_atis when the threshold was crossed, andoffline_syncrows land days later, so physical order correlates with arrival rather than withqualified_at. Checkpg_stats.correlationfor the column instead of assuming; poor correlation makes every block range a candidate and the index worthless. - Settle it with EXPLAIN (ANALYZE, BUFFERS) rather than argument, and look at estimated versus actual rows for the range predicate first. A plan that goes wrong at this size usually went wrong at the estimate, and a wrong estimate has a different fix from a wrong index.
- Sequence the work by cost and risk: rewrite the predicate first because it is free and takes no lock, measure, then build the covering index with CREATE INDEX CONCURRENTLY, and only then consider partitioning, which is a migration of the whole table rather than an addition to it.
Worked solution (30 min)
- Reproduce on a scaled copy — 50 million rows with the same monthly distribution and a realistic share of late-arriving rows — and record the EXPLAIN (ANALYZE, BUFFERS) baseline.
- Replace the expression with the half-open range, re-run, and compare estimated versus actual rows.
- Build
(status, qualified_at, rights_holder_id)with CREATE INDEX CONCURRENTLY, re-run, and read the Heap Fetches line. - VACUUM the table and re-run, comparing Heap Fetches before and after.
- Read
correlationfrompg_statsfor bothqualified_atand the insert-ordered timestamp, and decide from those numbers whether BRIN was ever viable.
Expected result: The rewrite alone replaces a default selectivity guess with a histogram-based estimate. With the covering index the plan becomes an index-only scan whose Heap Fetches fall toward zero after a vacuum. Correlation is near 1 for the insert-ordered column and materially lower for qualified_at, which is why BRIN on qualified_at is not the cheap win it appears to be.
Verify it yourself
- The
date_truncform and the range form return identical counts, including for rows exactly on the month boundary instant. - EXPLAIN shows
statusandqualified_atas index conditions rather than filters. - Heap Fetches drop after VACUUM, confirming the visibility map is the dependency rather than the index.
- Buffers read by the winning plan are a fraction of the table's total pages, not equal to them.
Follow-up
- The close also needs plays that qualified in August but arrived in September. Which of your changes still works, and which partition does a late row live in?
- The same aggregate is now wanted per territory as well. Do you widen the covering index or accept the scan?
- How would you prove the scan really is index-only in production rather than on a freshly vacuumed copy?
How do you balance client-side performance with server-side processing requirements?
Medium · system design and architecture
Approach
- Name the failure you are designing for, then the recovery path.
- Fix the scope first: who calls this, how often, and what they do when it fails.
- State the consistency you need, and where you are willing to be stale.
Follow-up
- How does this behave when that dependency is down for an hour?
- What breaks first when traffic grows ten times?
What strategies would you implement to ensure high availability during peak traffic events?
Medium · system design and architecture
Approach
- State the consistency you need, and where you are willing to be stale.
- Choose a partition key and say what query it makes expensive.
- Fix the scope first: who calls this, how often, and what they do when it fails.
Follow-up
- What would you drop to keep the system up under load?
- What breaks first when traffic grows ten times?
Rate limit licence issuance through a scheduled live start
Medium · rate-limiting · thundering-herd · backpressure
The DRM licence service handles one request per playback start plus one per key rotation. Baseline tracks session starts at 10^3 to 10^4 QPS, but a scheduled live event concentrates the entire audience into a 60-second window and drives a 10x spike with no ramp. Callers are player SDKs on devices you cannot patch, each with its own retry loop. Licences cannot be cached or pre-issued, because this endpoint is the only place entitlement is enforced. Design the rate-limiting contract: the dimension you limit on, the refusal, the client's required behaviour, and what you provision instead.
Approach
- Separate the two jobs that get conflated into one limiter. Per-caller quota is a fairness and abuse control, keyed on account or device, a token bucket with a small burst allowance. Global admission control is a capacity control that sheds any caller once in-flight concurrency exceeds what the service can serve inside its latency budget. A per-account limit tight enough to protect the service refuses legitimate households during the spike; a global shed with no per-account limit lets one abusive key consume the headroom you provisioned for everyone.
- Do not key the limiter on session_id or key_id. Live channels rotate keys, so a long legitimate session makes many licence requests over hours, and limiting on the rotating dimension punishes exactly the sessions the event consists of.
- Refuse with 429 and an explicit Retry-After, chosen per response and drawn over a window rather than fixed. A constant Retry-After against a synchronised audience reassembles the identical spike one interval later, which is the failure the limiter was installed to prevent. Spreading arrivals is the whole job, so the server hands each refused caller a different deadline instead of hoping clients disagree by accident.
- Pin the client's obligations in the SDK specification, because the devices cannot be patched later: honour Retry-After when present, otherwise exponential backoff with full jitter, a bounded attempt budget, and a user-visible failure at the end rather than an indefinite loop. Add a per-attempt deadline — a client holding a request open for thirty seconds occupies a server slot it will never use.
- Name what you provision rather than shed. The spike is set by a clock, so it is forecastable: scale ahead of the scheduled start. If you must reduce requests, lengthen that channel's key-rotation interval, which trades a known amount of protection for a known amount of load. Never reach for caching or pre-issuing a licence: that deletes the only enforcement point in the system while every latency and error dashboard improves.
Worked solution (25 min)
- Compute the spike: take a baseline QPS, apply the 10x multiplier over a 60-second start window, then add the steady rotation rate for the duration of the event.
- Write the two limiters as separate rules with separate keys, and state the intended victim of each in one line.
- Write the 429 response with its headers, then compute the arrival distribution for 500,000 refused clients under Retry-After of exactly 1 second versus a value drawn uniformly over 10 seconds.
- Write the client retry pseudocode with full jitter and a bounded budget, then state the residual peak you must still provision for.
Expected result: Two independent limiters with the dimension and target of each; a 429 carrying a server-chosen jittered Retry-After; a client retry specification with bounded attempts, full jitter and a per-attempt deadline; and an explicit statement that the residual spike is provisioned rather than shed because entitlement cannot move off this path.
Verify it yourself
- No proposed mitigation caches a licence, pre-issues one, or extends a licence's life beyond what the entitlement check at issue time justifies.
- You can state the peak arrival rate your Retry-After distribution produces as a number — 500,000 at one instant versus 50,000 per second across ten — not as an assertion that it 'spreads load'.
- No limiter key includes session_id or key_id, so a three-hour live session that rotates keys is never refused for being long.
Follow-up
- The limiter is itself a shared counter. What happens to it at 10x, and what accuracy do you give up to keep it off the hot path?
- An entire ISP sits behind a handful of egress addresses. Which of your dimensions breaks, and what do you key on instead?
Origin egress quadruples while viewer demand stays flat
Easy · cdn caching · cache key · origin load
Overnight, origin egress for segments rose about fourfold while edge-delivered bytes, concurrent sessions and peak bitrate stayed flat. Segment hit ratio at the edge fell from 95% to 81%; manifest hit ratio is unchanged and every POP is affected equally. No playback alarm fired, so the first signal was a cost alert. The previous evening's release added a signed delivery token to segment URLs. Produce an ordered diagnostic checklist, name the change that explains the numbers, and show the arithmetic linking hit ratio to origin load.
Approach
- Establish that demand did not change before touching any cache config: compare edge bytes delivered, concurrent sessions and requests per second week over week. Origin volume is (1 - h) x edge volume, so h moving 0.95 to 0.81 takes the origin share from 0.05 to 0.19, a 3.8x rise. The observed numbers are already self-consistent, which rules out a traffic story.
- Slice hit ratio by path prefix, content type and POP. Segments down, manifests flat, all POPs equally affected points at a rule change applied to the segment path, not a regional failure, a purge storm or an eviction problem.
- Read the cache key the edge actually computes for a sampled segment request rather than the configuration you believe is deployed: which query arguments, headers and cookies are included. Diff it against the previous release.
- Count distinct cache keys per segment URL over one hour. A ratio near one key per viewer is proof the token is in the key; a ratio near one is proof it is not, and sends you to storage suppression instead.
- If the key is clean, check origin responses on segments for Set-Cookie and for a Vary on a per-request header, either of which stops a shared cache storing the object independently of the key.
- Fix by keeping the token in the URL for the edge to verify and excluding it from the key. Verification must be signature-only at the edge (HMAC or public-key over path plus expiry) so it needs no origin callback. State the trade-off out loud: one cached object now serves every holder of a valid token, so token lifetime and path scope become the only limits on URL sharing.
Follow-up
- How do you rotate the token signing key without breaking sessions already in flight?
- What token lifetime do you choose, given a segment stays cached for hours and a single playback can run three hours?
- Why would a staging test with twenty clients never surface this, and what test would?
Suggested practice checklist
For someone who has spent the last few years shipping features and reading other people's code, and who has not solved a timed problem from a blank file in a long time. Five days rebuild the primitives and the patterns that sit on them, working from invariants rather than remembered solutions, and the last two attach that back to the rest of the loop.
Day 1 — Rebuild the primitives by implementing them
- Implement a dynamic array with doubling growth and an operation counter, then change the growth rule to add a fixed sixteen slots instead, and time both for n of ten thousand, a hundred thousand and a million. The fixed-increment version resizes n/16 times at O(n) each, so its total work is quadratic; doubling is what makes append amortised constant.
- Implement a hash map with separate chaining and a load-factor resize, then insert ten thousand keys engineered to land in one bucket and record what happens to lookup time, so that average-case O(1) becomes a claim with a stated precondition rather than a reflex.
- For dynamic-array append and hash-map insert, write down which cost is amortised rather than worst-case, which single operation pays the whole bill, and what a system with a hard per-operation deadline would have to do instead.
Deliverable: Two working implementations plus a timing table showing the input at which each structure's advertised complexity stops holding.
Day 2 — Arrays under an invariant: two pointers, sliding window, binary search
- Solve longest-subarray-with-sum-at-most-K using a sliding window, then run it on an input containing negative numbers and watch it return the wrong answer: extending the window only moves the sum monotonically when every element is non-negative, and that precondition is the whole reason the technique works.
- Write the binary search that finds the first index satisfying a predicate rather than an exact value, put the loop invariant above the loop in a comment, and verify termination on the two inputs that break careless versions: the empty range, and a range where every element satisfies the predicate.
- Compute the midpoint as lo + (hi - lo) / 2 and write one line on why the obvious (lo + hi) / 2 is a genuine defect in a fixed-width integer type and a non-issue in a language with arbitrary-precision integers.
Deliverable: Three solved problems, each with its invariant written above the loop, plus one recorded input on which the sliding window is provably wrong.
Day 3 — Sorting, heaps, and the greedy argument that has to be proved
- Solve one top-k problem three ways, by full sort, by a size-k heap, and by quickselect, then write the values of n and k at which each becomes the right choice, along with quickselect's quadratic worst case and why a randomised pivot makes that unlikely rather than impossible.
- Implement bottom-up heapify and count sift-down steps to confirm it does linear work rather than n log n, because most nodes sit near the bottom of the tree and therefore move only a short distance.
- Take interval scheduling by earliest finishing time and write the exchange argument out in full: given any optimal schedule, swapping in the earliest-finishing interval keeps it feasible and no smaller. Then construct the weighted variant where that same greedy fails and name what has to replace it.
Deliverable: A three-way top-k comparison with measured crossover points, one written exchange argument, and one counterexample to a greedy rule that looks almost identical.
Day 4 — Recursion, memoisation, and the step to a table
- Take one problem with overlapping subproblems, such as edit distance or coin change, instrument the plain recursion with a call counter to show the blow-up, then add memoisation and re-count.
- Convert the memoised version to a bottom-up table and state the two properties you relied on: each subproblem's result depends only on its arguments, and the dependencies form a DAG you can enumerate in order.
- Rewrite one deep recursion with an explicit stack, then find the input length at which the original hits the interpreter's frame limit, which defaults to about a thousand frames in CPython, so you know when the rewrite is required rather than decorative.
Deliverable: One problem in three forms, naive, memoised and tabulated, with call counts for each and the input length at which recursion depth becomes the binding constraint.
Day 5 — Graphs, where most of the work is choosing the traversal
- Implement BFS and DFS over one adjacency list, then answer for each which finds a shortest path in an unweighted graph and which you would use to detect a cycle in a directed graph, including why the in-progress versus finished distinction matters for the second.
- Implement topological sort by in-degree, feed it a graph containing a cycle, and confirm the failure signature is that fewer than V nodes come out rather than an exception, then note that the order it produces is one of several valid ones.
- Run a shortest-path search on a graph with a single negative edge weight and show the wrong answer, then write the precondition Dijkstra actually needs, non-negative weights, because it finalises a node's distance the first time that node is popped, and name the algorithm you would switch to and its own limit.
Deliverable: A small graph library with BFS, DFS and topological sort, plus two inputs that produce documented wrong answers under the wrong algorithm choice.
Day 6 — One day for everything that is not an algorithm
- Sketch one system only to the depth a coding-heavy loop tends to reach: the endpoints, what the service stores, and the single query pattern that decides the schema. Stop at twenty-five minutes.
- Prepare the project answer for an interviewer who codes, which means rehearsing the two levels they push to: the specific thing you built, and why you chose that approach over the alternative they will name. Open with a number and be ready to say what it excludes.
- Prepare the answer to what you would do differently, choosing a real technical mistake with a specific fix rather than a complaint about process or staffing.
Deliverable: One design sketch at endpoint-and-schema depth, plus a project answer rehearsed to two levels of follow-up.
Day 7 — Solve out loud, under time
- Do three timed problems at twenty-five minutes each in a plain editor with no autocomplete and no execution until the end, then tally separately the failures that were syntax and the ones that were approach, because those two numbers call for different fixes.
- Narrate one solution from the first sentence, stating the approach and its complexity before writing any code, and rehearse the sentence you will use when you realise mid-solution that the approach is wrong.
- Re-solve from blank the two problems you were slowest on this week and compare the times against the day they first appeared.
Deliverable: A recording of one fully narrated solution and a tally that separates syntax failures from approach failures.
Know where your numbers came from
Bring the two or three numbers the story rests on and know how they were collected. A p99 whose timer starts inside your handler excludes the time a request spent queued, so it can sit flat while users wait longer. Give the window, the percentile and what the measurement left out, or drop the number.
Turn an inherited offline licence expiry into a real decision
Medium · offline drm · rights expiry · decision records
license_grant.policy_expires_at for offline downloads is 30 days everywhere, inherited from a library default nobody chose. It is set at issue time and is the only enforcement that reaches a device which never reconnects. Some availability windows close sooner than 30 days. Describe turning an implicit behaviour into an explicit, owned decision: how you surfaced it, who had to be in the room, the rule you proposed and its cost to viewers, and how you verified it took effect.
Approach
- The probe is whether you can convert a default into a decision without theatre. Quantify first: count persistent grants whose policy_expires_at is later than the ends_at of the active availability_window for that content version and territory. That turns a feeling into a row count and a worst-case overrun in days.
- State the mechanism precisely, including its escape hatch, because overstating it costs you the room. The policy on an issued licence is fixed on the device, so a replacement or revocation lands only if the device reconnects. The 30 days is a worst case over the population that never comes back, not a claim about every download.
- Propose the rule as a computation at issue time rather than a smaller constant: policy expiry equals the minimum of the product default, the issuing territory's window ends_at, and the entitlement horizon such as the paid-through date. Name the viewer cost in the same breath - a download taken the night before a window closes now expires the next day, which reads as a bug to someone on a flight - and say what the client shows them.
- Name who must agree and what each of them is actually deciding: the rights owner on the bound, product on the experience of a short expiry, and engineering on whether the window lookup is correct and available on the licence path at its request rate.
- Write it where it survives you. A decision record naming the rule, its owner, the three inputs to the minimum, and the null case: ends_at NULL means perpetual, and a comparison that treats NULL as a value yields either no bound or no window at all depending on the expression you wrote.
- Verify by re-running the count rather than reading the diff. After rollout the query from step one should return no new overrunning grants, and the pre-existing overhang should be a decaying population you can plot to the date it reaches zero.
Follow-up
- A window is extended after you issued a short licence. What does the viewer see, and what do you do about it?
- ends_at is NULL for a perpetual window. Show me exactly what your minimum computes.
- The licence path now depends on the availability service at roughly 10^3 QPS. What happens when it is unavailable?
Reverse your decision to halve the live segment duration
Medium · live latency · encoding · reversibility
You halved live segment duration from four seconds to two to cut latency, on the reasoning that a compliant client starts roughly three target durations behind the live edge. Latency improved. Segment and playlist request rates doubled, and at fixed bitrate quality dropped, because twice as many IDR frames consume bits the predicted frames around them were using. Describe a decision you reversed after production contradicted you: what you predicted, what you measured, what the reversal cost, and what you shipped instead.
Approach
- The probe is whether you separate the term you moved from the terms you did not. Decompose the budget - capture, encode, packaging, publish, edge propagation, and the client's own buffer ahead of the live edge - and say which one segment duration touches. Halving four seconds to two takes the buffer term from about twelve seconds to about six and leaves every other term untouched.
- Report what you measured afterwards, including the costs you had not predicted. Request rate scales inversely with duration: at 200,000 concurrent viewers, four-second segments are 50,000 segment requests per second and two-second segments are 100,000, with playlist refetches doubling alongside.
- Be exact about the quality term. IDR frames are intra-coded and cost several times a predicted frame, so doubling their frequency at a fixed bitrate takes those bits from the rest of the GOP. Report it as a quality metric at fixed bitrate on fixed clips rather than an impression, and name the content - the effect is largest on high-motion material.
- Name the arithmetic the change constrained. Boundaries must stay aligned across every video rung and land on IDR frames, so a segment has to be a whole number of frames: at 24000/1001 fps a nominal four-second segment is 95.904 frames, and the aligned choices are 4.004 s for 96 frames and 2.002 s for 48. Ignoring that is how one rung's boundaries drift after a re-encode while the others stay correct.
- State what reversal cost and why. On live the change takes effect at the next channel restart and no stored asset is rewritten, so it is a configuration change - unlike the on-demand equivalent, where the same reversal is a re-packaging pass over a catalogue.
- Close on the replacement. Low-latency extensions deliver sub-second parts within a segment that stays long enough to encode efficiently, which is the mechanism that breaks the coupling you ran into; name its costs too - per-part request rate, blocking playlist reloads, and a more demanding origin-to-edge path.
Follow-up
- Your DVR window is four hours. What does the playlist look like at two-second segments, and what does refetching it cost in bandwidth?
- How would you have caught the quality regression before shipping, and on what content?
- Once you adopt partial-segment delivery, which latency term dominates?
Resolve a review disagreement over watch-time accounting
Easy · code review · telemetry · idempotency
A colleague's pull request accumulates watch time by summing differences between reported playhead positions in the heartbeat stream. They point out that it matches the reference trace exactly and has run in staging for two weeks. The schema already has playback_session.played_ms, a monotonic counter reported by the client. Describe a code review disagreement where you were confident and the author was not convinced: how you made the failure concrete, how many rounds it took, when you would have approved anyway, and what you conceded.
Approach
- The probe is whether you turn an objection into something reproducible instead of a stalemate. Name the mechanism rather than the smell: a backward seek makes the position delta negative and a forward seek makes it as large as the jump, so a viewer scrubbing through a 45-minute title records either a negative watch or several hours of one.
- Address their evidence head-on. The reference trace is linear happy-path playback, where position deltas and elapsed play are the same number, so it cannot distinguish the two implementations at all. That sentence ends the argument faster than the theory does.
- Build a fixture rather than a paragraph: ten heartbeat rows for one session containing a backward seek, a forward seek, one duplicate and one out-of-order arrival, with the correct watch time written beside it. Ten rows settle in one round what four paragraphs will not settle in three.
- State the fix as a property rather than a patch. MAX(played_ms) per session_id is idempotent under duplicate delivery and independent of arrival order, because max is commutative, associative and idempotent - the same rows replayed in any order give the same answer. Sum-of-deltas has none of those properties.
- Name the precondition you are relying on, because a reviewer who hides one loses the next argument. MAX is correct only while the counter is monotonic within a session_id; a player that resumes after a crash and restarts the counter under the same id will undercount, which is why a resume mints a new client-generated session UUID.
- Say where your bar sits and what you gave up. Counting that money is paid against is a blocking comment and style is not; the author was probably right about something separable, and saying so is what makes the next blocking comment land.
Follow-up
- A firmware bug makes played_ms decrease once in a thousand sessions. What does your query do, and what should it do?
- How do you backfill the watch time already recorded under the old implementation?
- Which implementation survives a client delivering four days of buffered heartbeats after an offline trip?