cswork / libraryOpen practice workspace

Interview guides · Gopuff

Gopuff Software Engineer Interview Questions & Guide 2026

Debugging practice for Gopuff software engineer candidates: reproduce it, narrow it, then change one thing per run.

The 60-second brief

As a Software Engineer at Gopuff, you play a vital role in powering the technology behind instantaneous commerce. Your work directly impacts how millions of customers discover, order, and receive everyday essentials in a matter of minutes. This position requires you to build resilient, scalable backend services, high-throughput data pipelines, and responsive frontend features that operate under strict latency requirements. You will tackle complex technical challenges unique to rapid delivery logistics, real-time inventory management, and hyper-local supply chain networks.

Allocate preparation against your weakest link rather than your favourite topic. A strong algorithm habit usually comes with weak out-loud explanation of tradeoffs, and years of shipping usually come with rusty from-scratch implementation under a clock.

Focus areas

  • Reconcile money with append-only double-entry postings
  • Shard hot markets while preserving per-booking ordering
  • Make offer acceptance idempotent under expiry races

Overview

The scale and speed of Gopuff mean your contributions go live rapidly and have an immediate, measurable effect on business operations and user satisfaction. You will collaborate closely with product managers, data scientists, and operations teams to translate business needs into robust technical architectures. Whether you are optimizing search and discovery algorithms, scaling microservices to handle traffic spikes, or integrating third-party partnerships, your code drives the core engine of the company.

Expect a fast-paced environment where autonomy and technical excellence are highly valued. While the pace is demanding, you will have the opportunity to make significant architectural decisions and shape the future of instant delivery infrastructure. Success in this role requires a strong foundational knowledge of distributed systems, a pragmatic approach to problem-solving, and a collaborative mindset.

The Gopuff interview process

Gopuff candidates report 3 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.

Recruiter Screen

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

Technical Phone Screen

Half of this call is the part candidates treat as small talk: start date, notice period, work authorisation and its timing, location and time zone, on-call, and the number. Those are what kill offers late, after several engineers have each spent a day. Surfacing a hard constraint now costs you nothing and occasionally buys you something, since a loop compressed to fit a competing deadline can usually only be arranged if it is asked for early. The common failure is deflecting the compensation question twice, then discovering at offer stage that the band never reached your number.

What to demonstrate

  • Whether your hard constraints are compatible with the role before a loop gets booked: earliest start, notice period, what authorisation you hold and when it needs action, days on site, willingness to carry a pager
  • Whether you give a compensation range with something behind it, such as current total compensation or a competing timeline, rather than leaving the band untested
  • Whether your stated timeline is real, since a competing deadline raised now is something scheduling can sometimes work around and the same deadline raised at offer stage usually is not

How to prepare

  • Write each constraint down in one line before the call and state them as facts rather than negotiating them live under a question you were not expecting
  • Set your range from two or three current data points for that level and location, and name the structure you are quoting in, so the number is comparable to the one they are holding
  • If another process is running, say where it stands and by when, and ask directly whether this loop can be scheduled inside that window

Multi-Round Virtual Onsite

Where the day includes a partner from product, design or data, that conversation is weighted like the technical ones and prepared for least. They are deciding one thing: whether having you in the room makes their decisions cheaper. That means options with costs attached, not implementation detail and not "it depends". An estimate someone can plan against — a range, the assumption that would push it to the high end, and what you would drop to hit the low one — is worth more than a confident single number, which everyone present already knows is wrong.

What to demonstrate

  • Whether an estimate comes as a range with the assumption most likely to break it, and states what a specific scope cut would actually buy
  • Whether a technical constraint is handed over as a choice with consequences on their side, rather than as a verdict they have no standing to argue with
  • Whether you establish what decision is on the table before proposing anything
  • Whether risk is raised while it can still change the plan, with the trigger that would confirm it, instead of reported afterwards as a slip

How to prepare

  • Take a project that shipped late and write the two-sentence warning you could have given three weeks earlier, naming what you would have needed decided at that point
  • Rehearse one estimate out loud until it arrives in three parts: the range, the single assumption that would blow it, and the smallest thing you would cut to protect the date
  • Rewrite an objection you have actually made — the "we can't do that" version — as two options with their costs, so the choice ends up with the person who owns it

Where candidates lose points

Treating the provider's device as the source of truth for state

The app is offline part of the time and buffers transitions, so events arrive late, out of order, and occasionally duplicated after a retry. A status column written last-write-wins will therefore accept 'started' after 'completed' and resurrect a cancelled booking, which re-enables charging on it. Client timestamps make it worse because the device clock is user-settable and drifts, so ordering by them is not ordering at all. The server must assign the ordering (a per-aggregate monotonic sequence) and every transition must be a guarded write against the expected current state, with the client event treated as a request to transition rather than as the transition.

Publishing an event and committing the state change as two separate operations

Whichever order they are attempted in, a crash between them breaks the system in a specific way: publish-then-commit emits an event for a transition that never happened, so downstream consumers charge or notify for a phantom booking; commit-then-publish silently drops the notification, so the provider never receives an offer that the database believes was sent. Neither is fixed by retrying harder, because the failure is the gap, not the operation. The transactional outbox closes it — the event row is written in the same local transaction as the state change and a relay publishes it afterwards — at the cost of at-least-once delivery, which pushes the idempotency requirement onto every consumer.

Check-then-act on shared state

Read, decide, write is not safe under concurrency unless the decision and the write are one atomic step: a unique constraint with conflict handling, a compare-and-set, or a row lock held for the whole transaction. Two requests can both pass the existence check before either inserts, which shows up as duplicate rows under load and never in a single-threaded test.

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.

Questions & practice

How would you refactor a nested conditional function to improve readability and performance?

Medium · data structures and algorithms

Approach

  1. Choose the data structure from the access pattern, not from familiarity.
  2. State the target complexity and say which constraint rules the naive version out.
  3. Name the brute-force solution and its complexity before improving on it.

Follow-up

  • How does this change if the input no longer fits in memory?
  • Which test case would catch an off-by-one here?

Implement a caching mechanism with an eviction policy for frequently accessed catalog items.

Medium · data structures and algorithms

Approach

  1. Name the brute-force solution and its complexity before improving on it.
  2. Choose the data structure from the access pattern, not from familiarity.
  3. Restate the input: its shape, its size, and what is guaranteed about it.

Follow-up

  • How does this change if the input no longer fits in memory?
  • Which test case would catch an off-by-one here?

Given a standard array manipulation or string processing problem, implement an optimal solution and discuss its time and space complexity.

Medium · data structures and algorithms

Approach

  1. Restate the input: its shape, its size, and what is guaranteed about it.
  2. State the target complexity and say which constraint rules the naive version out.
  3. Name the brute-force solution and its complexity before improving on it.

Follow-up

  • What is the worst case, and how likely is it on real data?
  • Which test case would catch an off-by-one here?

Walk through your debugging process when a concurrent process throws a race condition error.

Medium · data structures and algorithms

Approach

  1. Name the brute-force solution and its complexity before improving on it.
  2. Walk one small example through your approach before writing the whole thing.
  3. Restate the input: its shape, its size, and what is guaranteed about it.

Follow-up

  • What is the worst case, and how likely is it on real data?
  • How does this change if the input no longer fits in memory?

Maximum offers to one provider in any rolling window

Medium · sliding window · two pointers · event stream · rate limiting

Given up to 20 million dispatch_offer rows as (provider_id, offered_at), sorted ascending by offered_at and stamped by the server rather than the device, compute for each provider the maximum number of offers falling inside any rolling 15-minute window, and return the providers whose maximum exceeds 20. Treat the window as half-open: (t - 900s, t]. Memory must be proportional to the offers active in a 15-minute span, not to the length of the input. State your time and space complexity.

Approach

  1. Keep one FIFO deque of timestamps per provider. On each arrival at time t, pop from the front while front <= t - 900; the deque's size is then the window count at t, and the per-provider maximum is the running max of that size. Each timestamp is pushed once and popped once, so the work is amortised O(1) per event and O(n) overall.
  2. Exploit the given ordering: because the global stream is sorted by offered_at, each provider's subsequence inherits that order and no sort is needed. If the input were unsorted, sorting would dominate at O(n log n) and would also force the whole stream into memory, defeating the space bound.
  3. Bound memory by construction: a deque can only hold events from a 15-minute span, so total memory is the peak offer count in any 15 minutes rather than n. Evict a provider's entry entirely once its deque empties and no offer has arrived for a full window.
  4. Be explicit about the boundary. With (t - 900, t], two offers exactly 900 seconds apart are never in the window together. Use the same convention in this alert and in the enforcement path, or the alert fires on a count the limiter never observed.
  5. Say why rolling rather than tumbling. Fixed 15-minute calendar buckets split any burst straddling a boundary, so a 30-offer burst centred on the boundary reports as roughly 15 and 15 and never trips a threshold of 20 — which is precisely the pattern the check exists to find.
  6. If only the boolean 'did it ever exceed 20' is needed, a ring buffer of the last 20 timestamps per provider suffices: the threshold is breached exactly when t minus the oldest of the last 20 is less than 900. That is O(1) space per provider and loses the actual peak value.

Worked solution (20 min)

  1. Take one provider with offsets in seconds from t0: 0, 100, 400, 850, 899, 900, 901, 1500, and a window of 900 seconds with the (t - 900, t] convention.
  2. Walk the arrivals, evicting the front while front <= t - 900, and record the deque size after each eviction pass.
  3. At t = 900 the eviction bound is 0, so the event at 0 is evicted (0 <= 0) and the count stays 5 rather than rising to 6.
  4. At t = 901 the bound is 1, nothing more is evicted, and the deque holds {100, 400, 850, 899, 900, 901}.
  5. At t = 1500 the bound is 600, so 100 and 400 are evicted and the deque holds {850, 899, 900, 901, 1500}.
  6. Recompute the same data with fixed tumbling buckets [0,900) and [900,1800) and compare.

Expected result: The maximum window count is 6, attained at t = 901 over the set {100, 400, 850, 899, 900, 901}. The sequence of counts is 1, 2, 3, 4, 5, 5, 6, 5. Tumbling buckets on the same data report 5 and 3, so a threshold of 6 would never fire.

Verify it yourself

  • At t = 900 the event at t = 0 must be evicted; a closed window keeps it and reports 6 one event too early.
  • After every eviction pass the deque's front is strictly greater than t - 900 — assert this rather than trusting the loop condition.
  • Each timestamp is pushed exactly once and popped at most once; instrumenting the counts confirms the amortised O(1) claim rather than asserting it.

Follow-up

  • Extend this to a 24-hour window over 200 million events — what breaks and what replaces the deque?
  • Events now arrive within seconds of real time and you must alert live; what does the restart story look like for this job?
  • A provider's device clock is three minutes fast and the client stamps the event — exactly where does that corrupt this computation?

Resolve accept-versus-expire races with a guarded conditional update

Medium · isolation levels · concurrency · constraints

Two dispatch partitions each read provider_presence.status = 'idle' for provider 88 from an asynchronous replica and each try to offer them a request. At the same moment the TTL sweeper is expiring offer 4471 while that provider taps accept. The database is PostgreSQL at the default READ COMMITTED. Write the exact statement that accepts an offer, the statement the sweeper runs, and the index DDL that makes a second concurrent live offer to provider 88 impossible. For every affected-row count your accept can return, say what the caller does.

Approach

  1. Make the accept a single guarded write rather than a read followed by a write: UPDATE dispatch_offer SET status = 'accepted', responded_at = now() WHERE offer_id = $1 AND status = 'offered' AND expires_at > now() RETURNING request_id, provider_id, quote_id;. The affected-row count is the authoritative answer to 'did I win', and nothing else in the system is.
  2. Explain why that is safe at READ COMMITTED specifically. When the sweeper holds the row lock, the accept's UPDATE blocks; on the sweeper's commit PostgreSQL re-evaluates the WHERE clause against the new row version, finds status = 'expired', and matches zero rows. A prior SELECT would have read a snapshot taken before the sweeper committed and decided on stale data, with nothing re-checking it.
  3. Map both outcomes to caller behaviour. One row means you won: insert the booking and the outbox row in the same transaction, and keep the payment gateway call outside it. Zero rows means you lost, and the caller must re-read to tell the provider why — expired, rescinded, or already declined — and must not retry the UPDATE, because a retry cannot change a terminal status.
  4. Give the sweeper the mirror-image guard so the two cannot both win: UPDATE dispatch_offer SET status = 'expired' WHERE status = 'offered' AND expires_at <= now();.
  5. Push the cross-partition duplicate down to the storage layer, since partition-local reasoning cannot see it: CREATE UNIQUE INDEX CONCURRENTLY provider_one_live_offer ON dispatch_offer (provider_id) WHERE status = 'offered';. The losing partition's INSERT raises SQLSTATE 23505, which the dispatcher treats as 'candidate taken, take the next one' rather than as an error. Pair it with the same shape on booking (provider_id) WHERE status IN ('accepted','en_route','arrived','in_progress').
  6. Compare with the isolation-level answer: SERIALIZABLE prevents the same anomaly but reports it as a 40001 serialization failure that every caller must be written to retry, and the retry arrives at the same decision — so you buy retry machinery to learn what the affected-row count already told you for free.

Worked solution (30 min)

  1. Open two sessions, run the guarded UPDATE for the same offer_id in both without committing, and watch the second block.
  2. Commit the first and observe the second return UPDATE 0 without error.
  3. Repeat the experiment with a naive SELECT-then-UPDATE and observe both sessions commit an accept.
  4. Create the partial unique index and attempt two INSERTs of an offered row for the same provider from separate sessions.

Expected result: Guarded: exactly one session reports UPDATE 1 and the other UPDATE 0, at READ COMMITTED, with no retry loop. Naive: both report UPDATE 1 and two bookings exist for one provider. With the partial unique index, the second INSERT fails with SQLSTATE 23505 instead of succeeding.

Verify it yourself

  • Dropping expires_at > now() from the guard lets a slow accept succeed after its deadline; keeping it makes the sweeper win deterministically.
  • Under REPEATABLE READ the losing session raises SQLSTATE 40001 instead of returning zero rows — your caller handles one or the other, and they are different code paths.
  • The partial unique index must not fire on terminal statuses: a second declined row for the same provider must still insert successfully.

Follow-up

  • The offer transition, the booking insert and the outbox row must commit together. Sketch that transaction, and say what goes wrong if the payment authorisation is inside it.
  • A partial unique index's predicate must be immutable. Why can it not be WHERE expires_at > now(), and what do you use instead?
  • The provider's phone times out and retries the accept, but the first call actually succeeded. What does the second call return under your design, and is that acceptable?

Index the outbox relay's claim query without scanning history

Easy · indexing · partial index · queue · outbox

outbox_event holds 400 million rows; at any moment roughly 2,000 have published_at IS NULL. Four relay workers drain it concurrently and must never hand the same event to two workers. A plain btree on created_at exists today and the claim reads 100 rows at a time, oldest first. Write the index DDL and the claim statement, say roughly how large the index is under each choice, and state what the relay's crash behaviour implies for consumers.

Approach

  1. Notice that the index order and the predicate are anti-correlated: published rows are the old ones, unpublished rows are the new ones, so WHERE published_at IS NULL ORDER BY created_at LIMIT 100 against a full btree on created_at walks from the oldest end through hundreds of millions of already-published entries before it reaches a claimable row. Selectivity is not the problem; scan direction is.
  2. Move the predicate into the index: CREATE INDEX CONCURRENTLY outbox_unpublished_idx ON outbox_event (created_at) WHERE published_at IS NULL;. It contains only the ~2,000 live entries, so it is a few pages instead of the roughly 10 GB a 400-million-entry btree costs, and a row leaves the index as soon as the relay sets published_at. The planner only uses it if the query's WHERE clause implies the index predicate, so the claim must spell published_at IS NULL out.
  3. Claim with row locks the other workers can step over: WITH c AS (SELECT event_id FROM outbox_event WHERE published_at IS NULL ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED) UPDATE outbox_event o SET publish_attempts = o.publish_attempts + 1 FROM c WHERE o.event_id = c.event_id RETURNING o.event_id, o.aggregate_type, o.aggregate_id, o.sequence_no, o.payload;. SKIP LOCKED is what makes four workers return disjoint batches without a queue-wide lock; without it the second worker blocks behind the first.
  4. Publish, then mark published in a later transaction. That ordering is deliberate: a crash after publishing and before the mark redelivers the event, which is at-least-once, and the consumer deduplicates on the (aggregate_type, aggregate_id, sequence_no) unique key with the dedupe row committed in the same transaction as its effect. The reverse ordering would drop events silently, which no retry can repair.
  5. State the alternative and its cost: deleting on publish keeps the table small and the vacuum profile flat, but discards replay and the audit trail. Keeping the rows plus a partial index preserves both, at the cost of a table that is almost entirely dead weight for this one query and needs its own retention job.

Follow-up

  • The relay sets published_at, a column the partial index's predicate references. In PostgreSQL, what does that do to HOT-update eligibility, and how does it change how often this table needs vacuuming?
  • An outage leaves 5 million unpublished rows. Which part of your design degrades first, and does the partial index still help?
  • Two workers claim disjoint batches and one crashes mid-publish. Walk through exactly what the downstream consumer sees and which uniqueness guarantee saves it.

Walk through how you would architect a search and discovery service that handles millions of queries with instant results.

Medium · system design and architecture

Approach

  1. Name the read and write paths separately; they rarely have the same bottleneck.
  2. State the consistency you need, and where you are willing to be stale.
  3. Name the failure you are designing for, then the recovery path.

Follow-up

  • What would you drop to keep the system up under load?
  • What breaks first when traffic grows ten times?

How would you design the system architecture for real-time inventory tracking across hundreds of micro-fulfillment centers?

Medium · system design and architecture

Approach

  1. Fix the scope first: who calls this, how often, and what they do when it fails.
  2. Choose a partition key and say what query it makes expensive.
  3. Name the failure you are designing for, then the recovery path.

Follow-up

  • How does this behave when that dependency is down for an hour?
  • What would you drop to keep the system up under load?

Explain how you would approach microservice decomposition from an existing monolithic application.

Medium

Approach

  1. Clarify what is being asked and what a complete answer contains.
  2. Say what you would check first and why it is the highest-information step.
  3. Work from the requirement backwards to the design.

Follow-up

  • What assumption would you test first?
  • How would you know your answer was wrong?

Return per-item outcomes from a batched presence ingest endpoint

Medium · partial failure · batching · backpressure

A fleet telematics gateway posts positions for up to 200 providers in one request every 4 s, updating provider_presence (provider_id, market_id, status, lat, lon, cell_id, updated_at, expires_at, eligibility_version). Some items fail: an unknown provider_id, a provider suspended by compliance, coordinates outside the market's bounds, and one storage shard briefly unavailable. Today any failure returns 400 for the whole request and the gateway re-sends the entire body. Design the request and response shape, the envelope-versus-item status semantics, the caller's rule for what to retry and what to drop, and how the server rejects a stale sample.

Approach

  1. Do the arithmetic first, because it justifies the shape: 100k providers at a 4 s heartbeat is roughly 25k samples per second, and batching 200 to a request turns that into about 125 requests per second. The batch exists to amortise request overhead on a write-dominated path, so anything that makes a whole batch fail undoes its reason for existing.
  2. Give every item a caller-supplied client_sample_id and answer with a result array of the same length, each entry carrying that id and a machine code: applied, stale, unknown_provider, ineligible, out_of_bounds, retry_later. One item's outcome never changes another's. Keep the envelope status for faults that really are whole-batch - authentication, an unparseable body, and a batch over the documented cap, which is a 413 - so the caller has one unambiguous rule about which level to read.
  3. Write the caller's decision table and make the counter-intuitive row explicit: retry_later items are not re-sent. Presence is last-writer-wins state with a TTL, so a 4-second-old position is already superseded by the next heartbeat and re-sending it wastes capacity during the exact incident that produced the failure. unknown_provider and ineligible are the only codes that change what the gateway sends next.
  4. Defend against reordering with a monotonic per-device sequence, not a clock: reject a sample whose sampled_seq is not greater than the one stored for that provider. Device wall-clock time is user-settable and drifts, so ordering by it is not ordering; the server stamps updated_at with its own receipt time and sets expires_at to receipt plus the TTL, which is what makes a provider who stops heartbeating fall out of dispatch on schedule.
  5. Shed load inside the batch rather than at its edge. Under saturation return retry_later for a fraction of items, degrading by market so one hot market suffers alone, and be explicit that the acknowledgement means accepted into the in-memory index - durable snapshotting is asynchronous, and promising durability here is what pushes 25k writes per second into the transactional store that holds bookings.

Worked solution (25 min)

  1. Compute the sample rate and the resulting request rate for the stated fleet size and heartbeat interval, and write both numbers down before choosing the shape.
  2. Define the request and response pair with the per-item id and the enumerated result codes.
  3. Write the caller's decision table: which codes are dropped, which change future sends, and which - none of them - are re-sent as-is.
  4. Implement the monotonic guard on sampled_seq and confirm that device time appears nowhere in the ordering logic.
  5. Send a batch containing one unknown provider, one suspended provider, one out-of-bounds coordinate and one shard failure, and assert the remaining 196 landed.

Expected result: 196 of 200 samples apply, the response carries four distinct per-item codes against the right ids, the envelope status is 2xx, and the gateway's retry policy re-sends nothing.

Verify it yourself

  • Replaying the same batch changes no stored row, because every sampled_seq is now not greater than the stored value.
  • Doubling the batch size halves the request rate without changing the applied-sample rate, confirming the cap governs tail latency and blast radius rather than throughput.
  • A whole-batch 4xx is reachable only through authentication, an unparseable body, or exceeding the size cap.

Follow-up

  • The gateway's connection drops after the server applied 180 of 200 samples. What does it send next, and why is that not a correctness problem here but would be for bookings?
  • How does the cap interact with tail latency and with the blast radius of one bad request?
  • A provider's eligibility_version changes between two batches. Where is that read, and why not cached at the gateway?

Cancellations fail hourly while the outbox relay falls minutes behind

Medium · deadlock · lock ordering · outbox · postgres

Every hour on the hour, 2-4% of cancellations return 500 and the database log records deadlock detected (SQLSTATE 40P01); outbox_event lag climbs to eight minutes over the same window and then recovers. The cancellation transaction updates booking, then payment_attempt, then inserts ledger_entry and outbox_event. An hourly reconciliation job updates payment_attempt rows whose status is 'unknown', then updates the matching booking rows with UPDATE ... WHERE booking_id IN (...). Give the ordered diagnostic checklist and the fix.

Approach

  1. Read the deadlock report in the server log before anything else. PostgreSQL logs both processes, the statement each was running, and what each held and waited for; the detector fires after deadlock_timeout, one second by default, so a report exists for every occurrence. That report usually names the two lock orders outright and saves the entire investigation.
  2. Confirm the ordering conflict it describes. The cancellation path locks a booking row and then a payment_attempt row; the reconciliation job locks payment_attempt rows and then booking rows. Two transactions taking the same pair in opposite orders deadlock by construction, and the hourly schedule is why the rate is periodic rather than correlated with traffic.
  3. Note the detail that makes the batch worse than it looks: UPDATE ... WHERE booking_id IN (...) acquires row locks in whatever order the plan produces rows, not in the order of the IN list. Two overlapping batches can therefore deadlock with each other while both developers believe they used the same order. Imposing an order requires an explicit SELECT booking_id FROM booking WHERE booking_id = ANY($1) ORDER BY booking_id FOR UPDATE before the update.
  4. Account for the lag separately instead of assuming one cause. A deadlock victim rolls its whole transaction back, so the outbox rows it wrote disappear and are rewritten on retry; the lag spike is that retry backlog, not a relay defect. Verify by checking whether relay sessions are actually blocked -- a lock wait event in the activity view -- or merely behind, and confirm its claim query is still using the partial index on created_at where published_at is null with FOR UPDATE SKIP LOCKED so workers do not serialize.
  5. Fix in two parts. Give every multi-row path a canonical lock order, ascending booking_id imposed by an explicit ordered SELECT ... FOR UPDATE, and chunk the job so lock hold time is bounded by a chunk rather than by the whole run. Then shorten the cancellation transaction so it does not hold a booking row lock across payment work at all: moving the money work to its own consumer off the outbox removes the pairing instead of ordering it, which is the durable version of the fix.
  6. Keep retrying 40P01, but correctly. The transaction rolled back completely, so a retry is safe and required; bound it, add jitter, and enable log_lock_waits so contention that has not yet escalated into a deadlock becomes visible before the next incident.

Follow-up

  • The job now runs in 200-row chunks. What is the worst-case lock hold time, and what does chunking do to the deadlock rate compared with one large transaction?
  • If you switched to SERIALIZABLE, which of these failures disappears and which new error would every caller have to handle?
  • How would you distinguish an outbox gap caused by rolled-back transactions from a relay that crashed mid-batch?

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.

Disagreement needs an outcome you did not choose

Conflict answers where you were right and everyone came round are the weakest ones. Stronger: the evidence you went and collected, what would have changed your mind, and what you did in the weeks after the call went against you. Implementing a design you argued against, properly, is a specific and checkable behaviour.

Tell me about a time you had to deal with severe ambiguity in a project requirements phase.

Medium · behavioural and engineering judgement

Approach

  1. Give the blast radius: what could have broken, and what you measured.
  2. Name the disagreement and how you resolved it with evidence.
  3. State the situation in two sentences and spend the rest on the reasoning.

Follow-up

  • How did you know your change caused the improvement?
  • What would you do differently if you ran that again?

Tell me about a complex project you delivered from conception to production, and what you learned from the experience.

Medium · behavioural and engineering judgement

Approach

  1. State the situation in two sentences and spend the rest on the reasoning.
  2. Name the disagreement and how you resolved it with evidence.
  3. Pick a story where you made the decision, not one where you watched it.

Follow-up

  • How did you know your change caused the improvement?
  • What would you do differently if you ran that again?

Shipping to a fixed date with debt you chose deliberately

Medium · technical debt · deadline pressure · risk

Describe a launch where you knowingly shipped something wrong: position heartbeats written straight into the transactional database, a status transition without a version predicate, a payout job that was not idempotent per provider and period. State the date pressure and who set it, the exact shortcut, and the failure you were accepting. Then say what kept the debt visible - an alert with a threshold, a load ceiling that fails closed, a kill switch, a ticket with a named owner. Finish with whether it was ever repaid, and if not, why not.

Approach

  1. Make the shortcut specific enough to criticise. 'We took on tech debt' cannot be graded; 'we shipped without the incremental-authorisation path, so any fare that grew past the hold became an uncollectable receivable' is a decision with an edge someone can push on.
  2. Bound the exposure with arithmetic you held at the time, not in hindsight: what share of bookings could reach the case, the cost per occurrence, and the volume at which it stops being tolerable.
  3. Describe the containment, because containment is the whole difference between deliberate debt and negligence. An alert on the condition, a flag that disables the path, a hard cap that fails closed, or a written expiry date all qualify; a line in a retro does not.
  4. Report repayment truthfully, including the common real answer that it still runs today. Then say what would have to be true for that to be acceptable - usually that the exposure estimate is still valid at current volume, which is a thing you can check rather than assert.
  5. Name what you refused to cut and why the line sat there. Deadline answers are graded on where you stopped, not on how much you were willing to ship.

Follow-up

  • What did the alert actually fire on, and did anyone act the first time it fired?
  • What was the second thing you were asked to cut, and what made you refuse it?
  • If the date had moved by a week, which of those two decisions changes?