As a Software Engineer at the University of Birmingham, you play a pivotal role in maintaining the digital infrastructure that supports world-class research, teaching, and administrative operations. This position is not merely about writing code; it is about building robust, scalable solutions that enable thousands of students and academic staff to access critical learning resources and institutional services. You are a key contributor to the university’s mission of digital transformation, ensuring that technology serves as an enabler for academic excellence.
Your work will involve navigating complex, multi-stakeholder environments where your technical output directly influences the efficiency of the university. Whether you are working on system integrations, developing digital learning content, or building internal tools, you will be expected to balance technical precision with the practical needs of a diverse user base. This role offers the unique challenge of working within a large-scale, historic institution that is constantly evolving its technological footprint to meet modern demands.
Virtual Pre-Screening
reportedYou cannot drill a format you do not know, so put the preparation into material that travels. Three pieces of your own work, each rehearsed until you can take a follow-up you did not anticipate, will carry a conversation or a code walkthrough equally well. Specificity is what separates that from filler. A number needs its definition before it means anything: a p99 is over some window and measured at some hop, and a server-side figure excludes the queueing and network time a client would see. The number you cannot qualify is the one to leave out.
What to demonstrate
- Whether your examples carry detail only someone who did the work would hold, such as what the binding constraint actually was, which alternative you rejected and why it was worse, and what you measured on each side of the change
- Whether a number survives one follow-up, meaning you can say what it was measured over and whether it moved because of your change or merely alongside it
- Whether a failure is described with the specific change that followed it, rather than a lesson stated in general terms
- Whether your part in a team effort is stated accurately, including what other people did
How to prepare
- Write a page on each of three projects covering the constraint, the option you rejected, the measurement before and after, and what went wrong. Cut any line you cannot take a follow-up on, since you are writing the parts you will be pressed on rather than a summary.
- Recover the real figures while you still have access: request volume, data size, latency with its percentile and window, team size, timeline. Note where each came from, whether a dashboard, a design document or memory, and mark the estimates so you can say which they are out loud.
- Take your weakest project story to someone who works in a different area and have them ask why four times in succession. The point where you run out of answer is the part to go and re-read before the round.
On-Campus Interview
reportedAn unlabelled round is first an information problem, and the cheapest information is free. Whoever schedules it can usually tell you how long it runs, who will be in the room and what they work on, whether you will be writing code and in what environment, and whether anything is being sent beforehand. Ask in writing so the answer is on record, then prepare for the two or three formats those answers still leave open instead of betting on one. What separates a strong candidate is not guessing right; it is having an opening that works whichever one it turns out to be.
What to demonstrate
- Whether you can start work from an ambiguous brief, since tolerating a vague scope without stalling is the same thing the job asks for
- Whether the questions you asked beforehand were ones that change your preparation, such as duration, medium and who is joining, rather than ones whose answers you could not have acted on
- Whether you adapt when the round turns out to be something other than what you were told, instead of spending the first ten minutes visibly recalibrating
How to prepare
- Send one short scheduling message asking four things: how long, who is joining and what they work on, whether you will be writing code and where, and whether to prepare anything in advance. Treat a vague reply as real information, since it means the round is loosely structured and you will be shaping it yourself.
- Write one opening that works in any of the formats still open: restate in your own words what you have been asked to do, then ask which of two directions is more useful to them. Say it aloud until it stops sounding recited.
- Set up for the two most likely formats before the call starts, with a blank editor in the language you would choose and a shared document you can type into, so a format surprise costs you nothing in the first minutes
PracHub editorial advice for the preparation topics above.
Running learner submissions without hard, enforced resource ceilings.
Submitted code is untrusted input written by people who are actively learning and sometimes actively probing. Without a wall-clock limit alongside the CPU limit, a sleeping or blocked process pins a worker; without a process/thread cap, a three-line fork bomb takes the host; without an output cap, a stray print loop fills the disk and the log pipeline; without network isolation, a submission can reach internal services or exfiltrate the hidden tests. Each ceiling has to be enforced by the kernel or the runtime, since a limit checked by the harness after the fact is not a limit.
Treating the launch subject identifier as a global user id, or merging identities on email.
The subject claim is stable only within its issuer, so the same human arriving from a second platform is a different subject and must be a second external identity bound to the same person. Email is worse than useless as a merge key here: privacy settings frequently suppress it from the launch entirely, districts recycle addresses between graduating and incoming students, and younger learners often have none. A merge on email eventually joins two children's work into one account, which is both a grade bug and a privacy incident.
Writing code before the input contract is pinned down
Before the first line, state the types, the size bounds, whether duplicates, negatives or an empty input are possible, whether the input is sorted, whether you may mutate it, and what the function returns when nothing matches. Every one of those answers changes the code, and discovering one at minute twenty costs a rewrite you no longer have time for.
Hardcoding to the sample inputs
Solve the stated problem rather than the two examples; special-casing a literal to make a sample pass is obvious immediately and reads as either a misunderstanding or an attempt to fake progress. If you genuinely cannot generalise yet, say which part is a stub and what would replace it.
Choose a category, try a prompt, then open its approach, worked solution or follow-up when you need it.
Validate an org hierarchy and resolve inherited entitlements
You hold a tenant's normalised org rows from one snapshot: org_id, parent_org_id (nullable), tenant_id, and a boolean saying whether that node carries an explicit content entitlement. There are up to 100,000 nodes and the feed is not trusted, so parent links may form a cycle, reference a node that does not exist, or point at another tenant's org. Reject the snapshot if the links do not form a forest inside one tenant, naming the offending nodes. Otherwise return, for every node, its nearest entitled ancestor including itself, or none.
Approach
- Validate the edge set before traversing anything. Each node has at most one parent, so the graph has at most V edges; reject any edge whose target is missing from the snapshot, and reject any edge whose target carries a different
tenant_id. The cross-tenant edge is the security-relevant one — it makes one district inherit another district's licensed content, which is the tenancy invariant failing through a data path rather than a query. - Detect cycles by reachability rather than by coloured recursion. Collect the roots (
parent_org_id IS NULL), build a child adjacency list in O(V), and run an iterative traversal from the roots. Any node left unvisited is on or below a cycle, because a finite functional graph where every node has one parent decomposes into trees hanging off cycles. To name a cycle, start from any unvisited node and follow parents into a visited set until a node repeats. - Make the traversal iterative with an explicit stack. A well-formed hierarchy is three or four levels deep, but a corrupt feed can produce a chain of 100,000 nodes, and recursive descent dies on it — CPython stops at a recursion limit of 1,000 by default, and a JVM thread's default stack overflows in the tens of thousands of frames. The failure arrives as a crash during ingest of a customer file, which is the worst place to learn this.
- Resolve entitlements on the way down, not on the way up. Push
(node, nearest_entitled_so_far)onto the stack; at each node the value is the node itself when it carries an explicit entitlement and the inherited value otherwise. One pass, O(V + E) time and O(V) space, no memo table needed. - State the alternative you rejected and why. Walking upward per node is O(V * depth), degrading to O(V^2) on the pathological chain the validator is there to catch; memoising the upward walk recovers O(V) amortised but needs a second map and still re-enters nodes. Top-down is strictly simpler here because you are already traversing for the cycle check.
- Report failures as a set, not the first one. Roster operators fix files in batches, so returning every missing parent, every cross-tenant edge and one representative node per cycle in a single pass saves a round trip per defect.
Worked solution 30 min
- Build three structures in one pass over the rows: an id set, a child adjacency map, and a root list, recording missing and cross-tenant parents as you go.
- Traverse iteratively from every root, carrying the nearest entitled ancestor down the stack and writing it into the result map on arrival.
- Compare visited count against node count; if they differ, walk parents from an unvisited node until a repeat to extract one concrete cycle.
- Run a fixture with a three-level district, one department whose parent is missing, one school whose parent belongs to another tenant, and a two-node cycle.
- Add a synthetic 100,000-node chain and confirm the traversal completes rather than overflowing.
Follow-up
- A node's parent changes between snapshots, moving a school to a different district. What must be recomputed, and what must not change about attempts already taken under it?
- Entitlements become time-bounded rather than boolean. How does the top-down pass change, and what is the new answer type?
- The snapshot is valid but 40 percent of nodes changed parent in one run. Is that a defect, and which gate decides?
Rank the longest-stuck grade publications in bounded memory
You are streaming score_publication rows to build an operator view: tenant_id, attempt_id, target, state, attempt_count, created_at, next_attempt_at, last_error_code. Up to 20,000,000 rows are in states pending and failed_retryable, spread across up to 5,000 tenants, and they arrive as an unsorted export you may not re-read. Return the 50 longest-stuck publications overall, the 5 longest-stuck per tenant, and a stuck count per tenant. Memory must be bounded independently of row count. State your bounds and the field you rank on.
Approach
- Pick the ranking field before the data structure, because this is where the answer is usually lost. Stuck duration is
now - created_at, not anything derived fromnext_attempt_at. Exponential backoff pushesnext_attempt_atfurther out with every failure, so ordering ascending by it surfaces the publications that just failed once and buries the row that has been retrying for six days — the ranking inverts exactly on the rows the operator opened the page for. - For the global top 50 — the 50 smallest
created_at— keep a max-heap of size 50 keyed oncreated_at. Push each row; when the heap exceeds 50, pop the maximum. The heap head is the youngest survivor, so a row older than the head displaces it and everything else is discarded in O(1) after one comparison. That is O(N log K) time and O(K) space, with the log K term only paid on the shrinking fraction of rows that beat the head. - For per-tenant results keep a map from
tenant_idto a size-5 max-heap plus an integer count. Memory is O(T * K') = 5,000 * 5 entries plus 5,000 counters, a few megabytes, and it is bounded by tenant cardinality rather than row count. Say that out loud: if tenants were unbounded this structure is not, and you would need a sketch or a two-pass job. - Do not sort. A full sort is O(N log N) over 20,000,000 rows and must materialise all of them; the heap never holds more than K. Note that a database
ORDER BY ... LIMIT 50reaches for the same bounded heap internally, so the interesting case is precisely the one stated — a stream you cannot re-read and cannot fit. - Carry the diagnostic fields through the heap entry rather than re-joining afterward. An operator needs
attempt_countandlast_error_codenext to the age to tell a receiver that is rate limiting from one that is rejecting the payload, and a second pass to fetch them defeats the single-pass constraint you just paid for. - Exclude
deadrows from these structures and count them separately. They are terminal by definition, so mixing them in lets a permanently dead publication occupy a slot in the top 50 forever while genuinely stuck rows rotate beneath it.
Follow-up
- Two publications share the same
created_atto the microsecond. What is your tiebreak, and why does a deterministic one matter for an operator page that refreshes? - The operator wants to replay the top 50 safely. What must be true of the idempotency key for that to be a no-op when the original attempt actually succeeded?
- Tenant cardinality rises to 5,000,000. Which part of your structure breaks first and what replaces it?
Backfill frozen lateness without converting a hundred million times
A backfill must set due_at_utc on 1,000,000 assignment rows where it is NULL, from due_at_local plus policy_time_zone, and then set is_late on the 100,000,000 attempt rows beneath them whose is_late is NULL, by comparing submitted_at to that instant. Converting one local time per attempt row is correct and far too slow. Give a plan that is both faster and reproducible when re-run months later, state the speed-up you expect, and define the result for a local due time that is ambiguous or nonexistent across a daylight-saving transition.
Approach
- Name why the naive plan is slow rather than asserting it. A local-to-UTC conversion is a binary search over that zone's transition table plus an object construction, on the order of one to three microseconds in a managed runtime; 100,000,000 of them is a few minutes of pure conversion and, once per-row fetch and allocation are included, hours. Nothing about it is wrong — it is the row count multiplied by a cost that did not have to be paid per row.
- Collapse the cardinality. The conversion depends only on
(policy_time_zone, due_at_local), and due dates cluster hard on end-of-day instants for school days, so the distinct pair count is on the order of 10,000 against 100,000,000 attempts. Convert the distinct pairs once into a small mapping, then resolve each attempt with a hash probe. That is roughly a 10,000-fold reduction in conversions, and the per-row cost drops from a conversion to a lookup. - Do it in two stages so the second one is a join, not a computation: materialise
due_at_utcon the 1,000,000 assignment rows from the mapping, then setis_lateon attempts by joining onassignment_id. The attempt stage then performs no time-zone work at all, which is also what makes it re-runnable without the tz database on the path. - Pin reproducibility explicitly. The local-to-UTC mapping is a function of the zone, the local time, and the time-zone database version, because releases correct historical transitions. Record the tzdata version on the backfill run, or a re-run months later can produce a different instant for the same row and a grade will move with no grade event behind it.
- Define the two degenerate cases instead of letting a library default decide. An ambiguous local time in a repeated hour maps to two instants; a nonexistent one in a skipped hour maps to none. Pick the rule — the later instant when ambiguous, the first instant after the gap when nonexistent — encode it as an explicit flag rather than relying on a fold default, and assert both in tests. An 11:59 PM due time is outside the affected hour for most zones, so this is rare, which is exactly why it will not be noticed when it is wrong.
- Respect the freeze. Update only rows where
is_late IS NULL; a row that already carries the flag was decided at submit and must not be recomputed, or the backfill becomes a retroactive regrade. Then chunk the update by primary-key range with a bounded transaction size, because one 100,000,000-row statement holds locks and generates dead tuples for the whole run and cannot be resumed after a failure.
Follow-up
- A district is moved to a different school with a different time zone mid-term. Which rows, if any, change, and what does the freeze rule say?
- The tzdata version on the backfill host differs from the one in the application image. How would you have found that out before the run rather than after?
late_policyisblockfor some assignments, so a late submission should not exist. What do you write for an attempt that submitted after the due instant under that policy?
Answer a point-in-time roster question against soft-deleted enrollments
enrollment(enrollment_id, tenant_id, section_id, person_id, role CHECK IN ('learner','instructor','aide','observer'), source, begin_date, end_date NULL, status, created_at, updated_at, deleted_at NULL) carries partial UNIQUE (tenant_id, section_id, person_id, role) WHERE deleted_at IS NULL. A learner withdraws in week 3 and re-enrols in week 6; one person is both aide and observer in the same section. Write the query returning learners enrolled in one section on a given date, name the index that serves it, and say what the deleted rows can and cannot tell you about a past roster.
Approach
- Separate the two time axes before writing anything. begin_date and end_date are valid time — when the enrollment was true in the world. deleted_at is transaction time — when the system stopped asserting the row. An 'as of date D' roster is a valid-time question, so the date predicate does the work and deleted_at only filters what you currently believe.
- Write it as: SELECT p.person_id, p.display_name FROM enrollment e JOIN person p ON p.person_id = e.person_id AND p.tenant_id = e.tenant_id WHERE e.tenant_id = $1 AND e.section_id = $2 AND e.role = 'learner' AND e.deleted_at IS NULL AND e.begin_date <= $3 AND (e.end_date IS NULL OR e.end_date >= $3) ORDER BY p.display_name. The role predicate, not DISTINCT, is what stops the dual-role person appearing twice.
- Notice what the partial unique index forbids: two live rows for the same (tenant, section, person, role) even when their date ranges are disjoint, so a genuine withdraw-and-return cannot be modelled as two live intervals. Either the first row is soft-deleted, which destroys the interval, or the uniqueness moves to EXCLUDE USING gist (tenant_id WITH =, section_id WITH =, person_id WITH =, role WITH =, daterange(begin_date, end_date, '[]') WITH &&) WHERE (deleted_at IS NULL), which needs the btree_gist extension and permits disjoint intervals while still rejecting overlaps.
- Index for the equality prefix: (tenant_id, section_id, role, begin_date) WHERE deleted_at IS NULL. Only the leading equality columns plus the first inequality are scan keys, so end_date belongs in the index for the visibility of the filter, not as a second range key.
- State the limit of a soft delete: it stores one bit and one timestamp, so it answers 'is this row still asserted', not 'what did we believe on D'. Reconstructing a past belief needs an append-only history row per change, or the recorded diff from the ingestion run; if support has to answer why a learner vanished, the run record is the artefact, not deleted_at.
Follow-up
- The feed sends two overlapping intervals for the same learner in the same section. Which one wins, and what does the exclusion constraint do to that apply?
- A learner withdrew but has a graded attempt in week 2. What does the gradebook show, and which predicate in your query decides it?
- How do you answer 'which learners did we deprovision last Tuesday' with this schema?
Enforce an attempt cap that two browser tabs cannot defeat
attempt carries UNIQUE (tenant_id, assignment_id, person_id, attempt_no), and assignment.max_attempts SMALLINT. Starting an attempt today reads the learner's existing attempts, computes the next attempt_no, and inserts a row with server_deadline_at = now() + time_limit_seconds. A learner clicks Start in two tabs five milliseconds apart under READ COMMITTED. Give the statements each transaction runs, name the anomaly precisely, and give two fixes: one that changes isolation level and one that is a single conditional statement. Say what the losing path must do about server_deadline_at.
Approach
- Trace the interleaving. Under READ COMMITTED each statement takes a fresh snapshot, and both SELECTs run before either INSERT commits, so both transactions see two existing attempts and both compute attempt_no = 3. The unique index rejects one with SQLSTATE 23505, which feels like the cap held; then the retry re-reads, computes 4, and inserts it. The cap is a predicate over rows that do not exist yet — a phantom — and uniqueness on a different key cannot enforce it.
- Isolation fix: run the whole start at SERIALIZABLE. Postgres's serializable snapshot isolation takes predicate locks over what the count read and detects the read-write conflict when both transactions insert into that range, aborting one with SQLSTATE 40001. The preconditions matter: every transaction touching that predicate must also be SERIALIZABLE, and the caller needs a retry loop, which is affordable here because the conflict scope is one learner on one assignment.
- Single-statement fix, and the cheaper one: put the counter on a row and make the check part of the write. UPDATE attempt_allocation SET attempts_used = attempts_used + 1 WHERE tenant_id = $1 AND assignment_id = $2 AND person_id = $3 AND attempts_used < max_attempts RETURNING attempts_used — zero rows means the cap is reached. Under READ COMMITTED the blocked second UPDATE re-evaluates its WHERE against the updated row after the lock is released, so it correctly returns nothing; the identical statement under REPEATABLE READ raises 40001 instead of re-evaluating, so the retry loop is mandatory there and unnecessary here.
- Handle the double click as what it is — one intent — with idempotency rather than concurrency control: accept a client-supplied start key and add UNIQUE (tenant_id, assignment_id, person_id, start_key). The second tab conflicts, the handler returns the existing attempt, and the cap never sees two requests at all. Concurrency control is the backstop for genuinely distinct starts.
- Freeze server_deadline_at at the winning insert and make the losing path return the winner's stored value. Recomputing now() + time_limit_seconds on the loser, or on any subsequent resume, hands the learner a fresh clock on every reload, which is the precise mechanism by which a timed assessment leaks time.
- Say what you would not do: SELECT ... FOR UPDATE on the assignment row serialises every learner in the section behind a single row at the bell, converting a per-learner conflict into a section-wide queue at exactly the wrong moment.
Worked solution 25 min
- Reproduce with two sessions interleaved by hand under READ COMMITTED and record that a cap of 3 yields 4 rows after the retry.
- Re-run both sessions at SERIALIZABLE and observe one abort with 40001 rather than a duplicate-key error.
- Replace the read-then-insert with the conditional UPDATE ... WHERE attempts_used < max_attempts RETURNING, and re-run under READ COMMITTED.
- Add the start_key unique constraint and fire fifty identical start requests, asserting one attempt row and fifty identical responses.
Follow-up
- A grader voids attempt 2. Does the cap free a slot, and what does the next attempt_no become?
- How many times do you retry on 40001, with what backoff, and what does the learner see while it retries?
- The allocation row is now a second write on the hottest path of the day. At what section size does it become the contended thing, and what would you do then?
How does your past experience align with the challenges of supporting …
How does your past experience align with the challenges of supporting a large-scale academic or public-sector institution?
Approach
- State your assumptions explicitly before working the problem.
- Work from the requirement backwards to the design.
- Clarify what is being asked and what a complete answer contains.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
What excites you most about the opportunity to work in this technical …
What excites you most about the opportunity to work in this technical capacity at the University of Birmingham?
Approach
- Clarify what is being asked and what a complete answer contains.
- State your assumptions explicitly before working the problem.
- Work from the requirement backwards to the design.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
Publish grades outward exactly once under retries and regrades
score_publication holds publication_id, tenant_id, attempt_id, target (lti_ags, webhook, sis_export), idempotency_key with UNIQUE (tenant_id, target, idempotency_key), score_given, score_maximum, score_timestamp, state (pending, in_flight, succeeded, failed_retryable, dead), attempt_count and next_attempt_at. The receiving endpoints are third-party: they rate-limit, they time out after having committed, and they can be down for days. Design the relay: how a row is claimed, what happens when the process dies after the HTTPS call but before the row is marked, how the idempotency key is derived, what stops a stale retry from overwriting a regrade, and which rows a claim is allowed to select. next_attempt_at is the only time column on the row; say what it means in each state.
Approach
- Write the score row and the publication rows in one transaction. That is the entire point of an outbox: a queue publish after the commit can be lost, and a queue publish before the commit can announce a transaction that then rolls back. There must be no window in which a grade exists with no recorded intent to deliver it.
- Claim with a single conditional statement rather than a read then a write, and let next_attempt_at carry two meanings: the backoff time for a row waiting to be sent, and the lease deadline for a row already out. UPDATE score_publication p SET state='in_flight', attempt_count = attempt_count + 1, next_attempt_at = now() + :lease FROM (SELECT publication_id FROM score_publication WHERE state IN ('pending','failed_retryable','in_flight') AND next_attempt_at <= now() ORDER BY next_attempt_at FOR UPDATE SKIP LOCKED LIMIT :n) c WHERE p.publication_id = c.publication_id RETURNING p.*. Both halves are load-bearing. Drop in_flight from the state list and that state becomes a one-way door: a row a process marked in_flight before dying matches no claim again, stalls permanently, and holds its tenant's backlog behind it. Drop the lease write and the row is re-claimable the instant it is claimed, because its next_attempt_at is already in the past, so every publication goes out once per polling worker. SKIP LOCKED is what lets N relay processes share one table without contending on the same head rows; the lease is what makes a crash recoverable. They are separate mechanisms and the design needs both. Index for the predicate with a partial index on next_attempt_at restricted to those three states, or the ORDER BY degrades into a scan over every succeeded row the table has ever accumulated.
- Accept that the transport is at-least-once and make the receiver's side idempotent instead of chasing exactly-once on the sender. A process that dies after the call and before the mark leaves the row in_flight with a lease deadline now in the past, and the next claim picks it up and re-sends it. Size :lease above the HTTP timeout plus a margin: a lease shorter than the call reclaims a request that is still running and sends the same grade twice concurrently. That duplicate is harmless only if the key is derived from (attempt_id, score revision) and nothing else. A UUID minted per retry, or a key containing a clock reading, makes every retry a fresh write at the receiver and turns one grade into several. Increment attempt_count at claim time rather than at completion, so a publication that kills its worker on every attempt walks its own counter to the cap and reaches dead, rather than being reclaimed forever.
- Treat ordering as a separate failure from duplication. A retry of an old score can land after a regrade's newer score, so score_timestamp must strictly increase per line item and learner; the AGS profile directs receivers to reject a non-increasing timestamp with a conflict, but a sender cannot assume every implementation honours it. Also suppress superseded rows at claim time, so a pending publication that is no longer the current revision for that attempt and target is retired rather than sent.
- Classify responses into distinct actions instead of one retry rule. A timeout is an unknown outcome and must be retried with the same key. A conflict on a stale timestamp is success-equivalent and should be marked, not retried. A rate-limit response honours a retry hint when present and otherwise backs off exponentially with jitter under a per-tenant concurrency cap. An expired credential is retryable only after refresh. A permanent rejection, such as a line item that no longer exists, is terminal and moves to dead with last_error_code recorded, because a row retrying forever looks identical to a healthy system on every dashboard.
Worked solution 35 min
- Write the transaction that inserts the score and its publication rows together, and state precisely what is lost if the publish happens outside it.
- Write the skip-locked claim, name every state its predicate selects, set the next lease deadline in that same statement, then trace a process that dies immediately after the HTTPS call through to the claim that picks the row up again.
- Derive the idempotency key from named columns and show two separate retries producing an identical key.
- Build the response classification table: timeout, stale-timestamp conflict, rate limit with and without a hint, credential expiry, line item gone, and server error, with the resulting state and next_attempt_at for each.
- Trace a regrade racing a stale retry and show which score the receiver ends up holding.
Follow-up
- A district's endpoint is down for four days and 200,000 publications accumulate. What happens when it returns, and what protects it from your entire backlog arriving at once?
- An instructor regrades one attempt three times inside a minute. How many outbound calls does the receiver see, and which score does it hold at the end?
- What does a support engineer see for a stuck tenant, and what exactly does a safe manual replay of a dead row do?
A key rotation at one platform takes down every tenant
At 08:00 launches begin failing with 'unknown key id' for 3 of 140 tenants. Within 90 seconds, p99 launch latency on every pod rises to 3 s and fleet-wide error rate hits 12%, including tenants on unrelated platforms. The upstream identity platform returns 429. Our outbound request rate to its key endpoint is 300/s, up from 0.02/s. On an unknown key id the verifier refetches the key set synchronously while holding the write lock on the shared key cache. Give an ordered diagnostic checklist, the root cause of the fleet-wide blast radius, and the fix.
Approach
- Order the checklist around the contradiction in the symptom, which is the fastest path in. First, scope the failure: 3 tenants fail with a specific error, but 140 tenants are slow. Two different populations means two different causes, and the interesting one is the second. Second, check outbound request rate to the dependency before checking the dependency's health, because a 15,000x increase in our own traffic reframes the 429 as self-inflicted. Third, take a thread or goroutine dump and look at what the blocked requests are waiting on. Fourth, read the code path that connects the two.
- Establish the trigger: one platform rotated its signing key, so every launch from its 3 tenants presents a key id the cache does not have. At bell volume that is thousands of cache misses within seconds, each one a synchronous fetch, with no single flight, no rate limit, and no negative cache. Our own retry behaviour produced the 429 that then fails those launches, which produces more misses.
- Establish the amplification separately, because it is the real finding: the refetch holds the shared cache write lock across network I/O. Every other launch on that pod, including ones whose key id is already cached and whose platform is fine, blocks behind a multi-second HTTP call. A per-tenant dependency failure becomes a fleet-wide latency failure through a lock, not through capacity.
- Fix the amplification first: never hold a lock across network I/O. Fetch outside the lock and swap the result in, or shard the cache per issuer so one issuer's refresh cannot block another's reads. That alone confines the blast radius to the 3 affected tenants.
- Then bound the fan-out: single flight per issuer so at most one refetch is in flight, a token bucket on refetches per issuer, and a short negative cache for an unknown key id so a forged or stale token cannot become a request flood. Serve the currently cached keys while a refresh is in progress rather than blocking on it.
- Then remove the trigger: refresh key sets proactively on a schedule and on the cache-control lifetime the endpoint advertises, so a rotation is absorbed before a launch depends on it. Reactive fetching on the critical path guarantees that every rotation is first observed as user-visible failure.
Follow-up
- The platform rotates without warning again while its key endpoint is genuinely down for 20 minutes. What does a launch see, and what do you cache to survive it?
- Where else in this system does a shared lock turn a single-tenant dependency failure into a fleet-wide one?
- How would you prove the negative cache cannot be used to lock out a tenant whose key id is legitimately new?
For someone fluent in a dynamic language who has shipped real work but has never had to say what the runtime is doing underneath. The week is built on measuring and deliberately breaking things, because the questions that expose this background are the ones where the interviewer asks why a second time.
Prepare, practise & reflect
One practical outcome each day. Spend longer where you need it.
0 / 7 done01Measure before reasoning
- Take a slow piece of your own code, write down in advance where you believe the time goes, then profile it and record how wrong the guess was. The cost is usually an allocation you did not notice or an accidental quadratic membership test.
- Replace one list membership test inside a loop with a set and measure at a thousand, ten thousand and a hundred thousand elements, confirming the shape of the curve rather than only that it got faster.
- Write down the three quantities you can now measure instead of assert: wall time, peak memory, and call count for the function you suspected.
Deliverable: A before-and-after profile of real code plus a written note on the size of the gap between the guess and the measurement.
Practice prompt ↗Practice prompt ↗Worked solution ↗02References, copies, and the bugs they produce
- Write the function with a mutable default argument, call it three times, and explain the accumulating result: the default is evaluated once when the function is defined, so every call shares one object.
- Build a nested structure, take a shallow copy, mutate an inner element, and show that both views changed, because a shallow copy duplicates the container and not the elements. Then fix it with a deep copy and state the cost you just accepted.
- Write two functions, one mutating its argument in place and one rebinding the local name, and predict the caller's view of each before running it. That single distinction produces most of the bugs that pass their tests.
Deliverable: Three small programs whose output you predicted correctly before running, each with a one-line statement of the rule underneath.
Practice prompt ↗Practice prompt ↗03Types, once, in a language that checks them
- Port one module you have already written, roughly a hundred lines, into a statically typed language, and record every place the compiler demanded an answer your original had left implicit: a value that can be absent, a numeric width, a case never handled.
- Write the same signature in both languages and state what the static one guarantees before the program runs and what it does not, since it will not save you from a wrong algorithm or an index out of range.
- Write the difference between an interface satisfied by declaration and one satisfied structurally, with one case each where the other approach would miss the mistake.
Deliverable: One module in two languages plus a list of the questions the type checker forced you to answer.
Practice prompt ↗Practice prompt ↗04Concurrency, starting with what actually runs at the same time
- Run the same CPU-bound function across four threads and four processes and measure both. Under the default CPython build the threaded version will not speed up, because only one thread executes bytecode at a time; the process version will. Check which build you are on first, since free-threaded builds remove that lock and change the result.
- Then run a blocking I/O workload across four threads and measure it speeding up, because the interpreter releases that lock around blocking calls, which is why treating threads as useless is wrong as a general claim.
- Build the lost update: two threads each incrementing a shared counter a hundred thousand times, and show a final value below the expected sum, because an increment is a load, an add and a store and the thread can be suspended between them. Fix it with a lock and then measure what the lock costs.
Deliverable: Three measurements, threads against processes on CPU work, threads on I/O work, and a demonstrated lost update, each with the mechanism written underneath.
Practice prompt ↗Practice prompt ↗Worked solution ↗05Debugging as a procedure rather than an instinct
- Work one real failure as a bisection: find a revision or an input size where it is good and one where it is bad, halve repeatedly, and state the two assumptions bisection needs, that the property changes exactly once across the range and that the test is reliable.
- Minimise one failing input to the smallest version that still fails, and record how many rounds it took.
- Keep a hypothesis log for one bug in three columns, what I believe, what would disprove it, what I observed, and stop yourself the first time you are about to change two things at once.
Deliverable: One bug worked to root cause with a written hypothesis log and a minimised reproducing input.
Practice prompt ↗Practice prompt ↗06Tests that catch the bug you are about to write
- Implement an LRU cache with a capacity bound, then write the three test cases that would catch an off-by-one in eviction: insert exactly capacity items and assert nothing was evicted, insert one more and assert the least recently used key is the one gone, and read an old key just before that insert so the eviction victim changes.
- Add a property test comparing your implementation against a deliberately slow reference, an ordered list scanned linearly, over a few thousand random operation sequences, because a slow reference finds the cases you would not have thought to write.
- Write one numeric test that fails under exact equality and passes with a tolerance, and state why the tolerance has to be relative rather than absolute once the magnitudes grow.
Deliverable: An LRU implementation with three boundary tests, one property test against a slow reference, and one tolerance-based numeric test.
Practice prompt ↗07Debug something broken, out loud
- Have someone plant three defects in a two-hundred-line program, an off-by-one, a shared mutable state bug, and a wrong error-handling path, then find them while narrating, under a fixed rule: state the hypothesis before touching anything.
- Time each one and record which tool found it, reading, a printed value, a debugger, or a test, because the question asked in interviews is how you would find it rather than what it was.
- Write the sentence you will use when you do not yet know the cause, one that names the next measurement instead of offering a guess.
Deliverable: A recorded debugging session with time-to-find per defect and the method that found each.
Practice prompt ↗Worked solution ↗Expand any day for tasks and deliverables. Your progress is saved on this device.
Keep one story where the bad call was yours rather than a dependency's or a manager's. Name the check that would have caught it, whether you added that check afterwards, and whether it has fired since. Answers that route blame outward end the conversation early; answers that end in a guardrail someone still relies on tend to open it up.
How do you handle situations where project requirements change due to …
How do you handle situations where project requirements change due to shifting organizational priorities?
Approach
- Pick a story where you made the decision, not one where you watched it.
- Name the disagreement and how you resolved it with evidence.
- Close with what you would do differently, concretely.
Follow-up
- What did you decide not to do, and why?
- What would you do differently if you ran that again?
When was your most stressful time and how did you tackle it?
When was your most stressful time and how did you tackle it?
Approach
- Pick a story where you made the decision, not one where you watched it.
- State the situation in two sentences and spend the rest on the reasoning.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?
Can you describe a project where you had to collaborate with various s…
Can you describe a project where you had to collaborate with various stakeholders to develop digital learning resources?
Approach
- Pick a story where you made the decision, not one where you watched it.
- Close with what you would do differently, concretely.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- What did you decide not to do, and why?
- How did you know your change caused the improvement?
- 01
How do you handle situations where project requirements change due to shifting organizational priorities?
- 02
When was your most stressful time and how did you tackle it?
- 03
Can you describe a project where you had to collaborate with various stakeholders to develop digital learning resources?
Is this an official University of Birmingham interview guide?
No. It is PracHub's own research and practice material for the Software Engineer role at University of Birmingham. Rounds and questions reflect what candidates have reported, not a process University of Birmingham has published, and they change over time. Confirm the current format and scope with your recruiter.
PracHub interview research ↗How long should I expect the entire process to take?
Candidates should be prepared for a process that can span several weeks, depending on the internal scheduling of the hiring team.
PracHub interview research ↗What is the best way to prepare for the presentation round?
Focus on clarity and impact. Choose a project that highlights your specific technical contributions and be prepared to answer deep-dive questions about your design decisions.
PracHub interview research ↗Is the work environment highly competitive?
Candidates describe a collaborative, supportive culture where team members are encouraged to help one another. Candidates report that the interviews favor people who thrive in a team-oriented setting.
PracHub interview research ↗What if I don't have experience in Higher Education?
While sector experience is beneficial, transferable skills also count. Focus on how your technical expertise can solve the types of problems the University of Birmingham faces, such as system interoperability and user-focused design.
PracHub interview research ↗Sources & methodology 3 sources ↗
No official company page is cited. Rounds and questions come from candidate reports and PracHub editorial material; each source shows the date it was read.
- 01PracHub interview research ↗
PracHub editorial research into this company and role, maintained with this guide. Candidate-reported, not an employer publication.
PracHub page · Accessed 2026-09-22 - 02PracHub Software Engineer practice ↗
Cross-company practice questions for this role.
PracHub page · Accessed 2026-09-22 - 03PracHub interview preparation framework ↗
The framework the preparation plan follows.
PracHub page · Accessed 2026-09-22