REST, HTTP and Git
Nobody asks these to find out whether you can send a request or make a commit. They ask to find out whether you know what a retry is allowed to do, which status code is a lie, and what happens to your colleague's clone when you rewrite history. Two subjects, one shape: the interesting half of both is what happens when something is attempted twice.
Safe, idempotent, and why anyone cares
SAFE means the request does not change server state - it is a read. GET, HEAD and OPTIONS are safe. IDEMPOTENT means making the request N times leaves the server in the same state as making it once. Every safe method is idempotent, plus PUT and DELETE. POST and PATCH are neither.
This is not taxonomy for its own sake. It is the contract that decides who may retry your request without asking you. A browser, a proxy, a load balancer, a service mesh and your own HTTP client will all happily re-send an idempotent request when a connection drops, because by definition that cannot do damage. None of them will retry a POST, which is why a flaky network turns 'charge the card' into a support ticket instead of a duplicate charge.
DELETE being idempotent surprises people, because the second call 404s. Idempotent is about the resulting STATE, not the response: after one DELETE the resource is gone, after five it is still gone. Same for PUT - the resource holds the body you sent, however many times you sent it.
- The one exception you build yourself
- POST is not idempotent, and sometimes it has to be retried anyway. An idempotency key - a client-generated unique id sent in a header - lets the server recognise a repeat and return the ORIGINAL response instead of doing the work twice. That is how payment APIs make 'charge once' survive a timeout, and naming it is what separates a good answer here from a textbook one.
- Git's version of the same question
- Every Git command is either safe to run on history other people have pulled, or it is not. revert, merge and cherry-pick add commits and are safe. reset --hard, rebase, commit --amend and force-push rewrite commits and are not - they replace commits with new ones that have new hashes, and everyone else's clone still refers to the old ones.
Questions
Answer each one yourself before you open it.
01What makes an API RESTful?
Resources identified by URLs, manipulated with the standard HTTP methods, with a uniform interface and stateless requests - every request carries everything needed to serve it, so the server keeps no client session between calls. Representations (usually JSON) are separate from the resource itself, and responses say whether they are cacheable.
The useful test is whether the URL names a THING and the method names the action: POST /orders and DELETE /orders/8, not POST /createOrder and POST /deleteOrder. If your API needs a verb in the path for anything but a genuine action endpoint, the resource is probably wrong. Worth saying out loud that REST is an architectural style, not a spec, and it says nothing about JSON - plenty of things called REST APIs are not, and it rarely matters.
Follow-up Why does statelessness matter in practice?
Because it is what lets you put ten identical servers behind a load balancer and not care which one gets the request. The moment a server holds session state in memory, you need sticky sessions or a shared store, and losing one instance loses its users' sessions. Statelessness is a scaling and failure property, not a purity one.
02Which HTTP methods are safe, and which are idempotent?
Safe: GET, HEAD, OPTIONS - they only read. Idempotent: all of those plus PUT and DELETE. Not idempotent: POST and PATCH.
It matters because it defines what infrastructure is allowed to retry for you. A proxy or a client library may re-send an idempotent request after a timeout with no permission from you, and it must not do that to a POST. That is also why 'GET must not have side effects' is a real rule and not a style preference - a crawler, a prefetcher or a link preview will fire your GETs without a user ever clicking.
Follow-up How can DELETE be idempotent if the second call returns 404?
Because idempotency is a property of the resulting state, not the response body or code. After the first DELETE the resource is gone; after the tenth it is still gone. Returning 404 or 204 on the repeats is a design choice, and neither breaks idempotency.
03PUT or PATCH - aren't they the same thing?
No. PUT REPLACES the resource with the body you send - anything you leave out is unset - and it is idempotent. PATCH applies a partial modification and is not idempotent in general. Sending PUT with three of eight fields should blank the other five.
The 'they're the same' belief comes from frameworks that route both to the same handler doing a partial update, which is technically a PUT that behaves like a PATCH. The reason to keep them apart is retries again: a PATCH of { balance: balance - 10 } applied twice is wrong, while PUT { balance: 40 } applied twice is fine. If your PATCH bodies describe absolute values rather than deltas, it happens to be idempotent - but the method does not promise it, so nothing will retry it for you.
Follow-up Can PUT create a resource?
Yes, when the client chooses the id - PUT /users/nova creates it if it does not exist and replaces it if it does, answering 201 or 200/204 accordingly. That is the difference from POST /users, where the server assigns the id and the call cannot be safely repeated.
04Which status codes should I know?
200 OK, 201 Created, 204 No Content. 301 moved permanently, 302 found, 304 not modified. 400 bad request, 401 unauthenticated, 403 forbidden, 404 not found, 409 conflict, 422 unprocessable content, 429 too many requests. 500 internal error, 502 bad gateway, 503 service unavailable.
The classes carry the meaning: 2xx it worked, 3xx go somewhere else or use your cache, 4xx you got it wrong and repeating it unchanged will not help, 5xx we got it wrong and retrying might. That last distinction is the one clients act on - a 4xx should not be retried, a 5xx and a 429 should be, with backoff.
05Which code do you return for a validation failure?
400 is always defensible. 422 is the more precise answer when the request was syntactically valid JSON that your rules rejected - the body parsed, the email is just not an email. Never 200 with an error object in the body.
The reason to care is that everything between you and the caller reads the status: monitoring, retry policies, client error handling, gateways. An error hidden in a 200 is invisible to all of it, and a caller who did the sensible thing (check res.ok) gets a success. Whichever you pick, return a machine-readable body naming the failing fields, and be consistent across the API - an inconsistent choice is worse than either choice.
06201 versus 200 versus 204?
201 Created for a successful creation, with a Location header pointing at the new resource and usually the created object in the body. 200 for a successful request that returns content. 204 for success with deliberately no body - a DELETE, or a PUT you have nothing to say about.
The 204 trap is on the client: res.ok is true, but calling .json() on an empty body throws. Any shared fetch wrapper needs to check for 204 (or an empty content-length) before parsing.
07301, 302, 307, 308 - what is the difference?
301 and 308 are permanent, 302 and 307 are temporary. The real difference is method preservation: 301 and 302 were historically rewritten by browsers to GET on a redirected POST, and 307 and 308 are specified to preserve the method and body.
Two practical consequences. A 301 is cached hard by browsers and is very difficult to take back - issuing one to the wrong place is close to permanent for anyone who already got it, so use 302 while you are unsure. And if you are redirecting an API call that carries a body, 307/308 is the only correct choice; 302 silently turns your POST into a GET and the body vanishes.
08What is a 304?
Not Modified - the answer to a conditional request. The client sends If-None-Match with the ETag it has (or If-Modified-Since with a date), and if nothing changed the server returns 304 with no body and the client uses its cached copy.
It saves the payload but not the round trip, which is what distinguishes it from a fresh cache hit: with Cache-Control: max-age still valid, the browser does not ask at all. Revalidation is the fallback for when freshness has expired, and it is why ETag on a large JSON response is cheap to add and worth adding.
09Authentication or authorization - and 401 or 403?
Authentication is who you are; authorization is what you are allowed to do. 401 Unauthorized actually means unauthenticated - no credentials, or bad or expired ones - and it should carry a WWW-Authenticate header. 403 Forbidden means we know exactly who you are and you still may not do this.
The naming is a historical mistake worth calling out, because it is the source of the confusion. The client behaviour differs and that is what matters: a 401 means re-authenticate - refresh the token, show the login screen - while a 403 means stop, logging in again will change nothing. An app that shows the login modal on a 403 puts the user in a loop.
Follow-up When would you return 404 instead of 403?
When admitting the resource exists is itself a leak. 'You are not allowed to see project 42' tells an attacker project 42 exists and who to target; a 404 tells them nothing. It is a deliberate trade of debuggability for confidentiality, and it is standard for anything with an enumerable id.
10JWT or session cookies?
A session cookie is an opaque id; the server looks up the session in its own store, so it is instantly revocable and holds no data on the client. A JWT is a signed token carrying claims, verified without a lookup - so it scales statelessly across services but cannot be revoked before it expires.
The trade is the entire answer: revocability versus a database lookup. If your reason for JWTs is 'stateless', check whether you kept a server-side allowlist to make logout work - because that is a session store with extra steps. The common middle ground is a short-lived access token (minutes) plus a long-lived, revocable refresh token, so the revocation window is bounded.
Follow-up What is inside a JWT and can you trust it?
Three base64url segments: header, payload, signature. The payload is ENCODED, not encrypted - anyone holding the token can read every claim, so nothing secret goes in it. You can trust the claims only after verifying the signature with the right key and checking exp, and checking the algorithm is what you expect rather than what the token says (the 'alg: none' and RS256-to-HS256 confusion attacks both come from trusting the header).
11Where do you store a token in a browser?
Refresh token in an HttpOnly, Secure, SameSite cookie; access token in memory only. localStorage is readable by any script on your origin, so a single XSS exfiltrates it.
There is no fully safe option, and saying so is the stronger answer. Cookies move the risk from XSS to CSRF, which SameSite and a CSRF token handle; localStorage moves it to XSS, which nothing except not having XSS handles. Memory-only for the short-lived token means a refresh reloads and re-fetches, which is the small cost you pay for the token not existing anywhere persistent.
12REST or GraphQL?
GraphQL when clients need different shapes of the same data and you are tired of either over-fetching or shipping a new endpoint per screen - the client asks for exactly the fields it wants in one round trip. REST when the resources are stable, HTTP caching matters, and you would rather have simple, cacheable, debuggable endpoints.
The costs are concrete and worth naming: GraphQL loses HTTP caching (everything is a POST to one URL), needs its own solutions for rate limiting and query-cost limiting because a client can ask for something enormous, and introduces the N+1 problem that dataloaders exist to patch. REST's cost is round trips and endpoint proliferation. Either is fine; picking one because it is fashionable is what the question is testing.
13What is an idempotency key?
A unique id the CLIENT generates and sends with a non-idempotent request - usually in an Idempotency-Key header. The server records it with the response; if the same key arrives again it returns the stored response instead of repeating the work.
It exists because of one specific failure: the request succeeded and the response was lost. The client cannot tell that apart from 'never arrived', so without a key its only options are to retry and risk a double charge, or not retry and risk losing the order. The key makes the retry safe, which is why every payment API has one. Generate it per logical operation, not per attempt, and keep it for at least as long as clients might retry.
14How would you rate limit an API?
A token bucket or sliding window per API key or user, enforced at the gateway. Over the limit, return 429 with a Retry-After header, and ideally X-RateLimit-Limit/Remaining/Reset so a well-behaved client can pace itself before it gets rejected.
Fixed windows are the naive version and they have a burst problem: a client can spend a whole window at 23:59:59 and another at 00:00:00, doubling the intended rate across the boundary. A sliding window or a token bucket smooths that. On the client side the matching behaviour is exponential backoff with jitter - without jitter, every rejected client retries at the same moment and you have built a thundering herd.
15How do you paginate?
Offset pagination - ?page=3&limit=20 - is simple, lets you jump to any page, and gives you a total count. Cursor pagination - ?after=<opaque token> - is stable and stays fast on large data sets, but you cannot jump to page 40.
The reason to prefer cursors on anything large is two-fold. Correctness: with offsets, a row inserted while the user reads page 1 shifts everything, so an item is shown twice or skipped entirely. Performance: OFFSET 100000 makes the database walk and discard 100,000 rows, and it gets slower the deeper you go, while a cursor is an indexed seek. Use offsets for an admin table of 500 rows and cursors for a feed.
16How do you version an API?
In the URL - /v1/orders - most of the time. It is visible, trivially routable, easy to cache and obvious in a log. The alternatives are a custom header or Accept media-type versioning, which are purer and much harder to test by hand.
The more useful half of the answer is avoiding a version bump at all: adding fields and adding optional parameters is backwards compatible, so only breaking changes need a version. And whatever you choose, a version is a promise to run two code paths - have a deprecation policy and a sunset date before you ship v2, or you will run v1 forever.
17What Cache-Control directives matter?
max-age for how long a response is fresh. no-cache means you may store it but must revalidate before using it. no-store means never write it down at all - that is the one for anything private. public and private say whether shared caches like a CDN may keep it. immutable says do not even revalidate.
no-cache and no-store are the pair people swap. The pattern that pays for itself: hashed asset filenames with max-age=31536000, immutable, and the HTML that references them with no-cache - so the assets are cached forever and never stale, because a change makes a new filename, and the entry point is always checked.
18500, 502, 503, 504 - which is which?
500 your application threw. 502 a gateway got an invalid response from upstream - usually the app is crashed or the port is wrong. 503 the service is up but deliberately unavailable - overloaded, or in maintenance - and it should carry Retry-After. 504 a gateway waited for upstream and timed out.
Being able to say which one points where is a debugging answer rather than a trivia answer: 502 says look at whether the process is running, 504 says look at what upstream is slow, 500 says look at your own logs. All are retryable in principle; 503 and 504 most sensibly with backoff.
19What does a good API error body look like?
A machine-readable code that will not change, a human-readable message that may, and field-level details for validation failures. Plus something to correlate with your logs - a request id. The status code carries the class; the body carries the specifics.
The failure to avoid is a message-only error, because the client then has to string-match your prose and breaks when you fix a typo. The other failure is leaking a stack trace or a SQL error to the caller, which is an information disclosure vulnerability. RFC 9457 problem details is a reasonable off-the-shelf shape if you want one.
20What does HTTP/2 change?
One connection, multiplexed - many requests in flight at once with no head-of-line blocking at the HTTP layer, plus header compression and binary framing. HTTP/3 moves the same idea onto QUIC over UDP, which removes TCP's head-of-line blocking too and survives a network change without a new handshake.
The reason it matters to a front-end answer is that it obsoleted a generation of optimisations. Concatenating every script into one bundle, sprite sheets, and sharding assets across four subdomains were all workarounds for the six-connections-per-origin limit, and under HTTP/2 they are neutral to harmful - many small cacheable files is now fine.
21A request failed. Do you retry?
Retry on a network error, a 429, and 5xx - with exponential backoff and jitter, a cap on attempts, and only for idempotent requests or ones carrying an idempotency key. Never retry a 4xx: the request is wrong and repeating it unchanged cannot help.
Honour Retry-After when it is present rather than using your own schedule. And the jitter is not a detail: without it, every client that failed during the same incident retries in lockstep and re-creates the load that caused the failure. A circuit breaker is the next layer up - stop calling entirely for a while once the failure rate is high, rather than queueing more work against a dying service.
22Explain the basic Git flow.
Edit files in the working directory, git add moves the changes you want into the staging area (the index), git commit records the staged snapshot with a message, git push sends your commits to the remote, git pull brings other people's down and integrates them.
Follow-up Why is there a staging area at all?
So a commit can be smaller than your working directory. You have three unrelated fixes in one file, and staging lets you commit them separately - git add -p walks you through hunk by hunk. Without it every commit is 'everything I have touched since the last one', which is what makes history unreadable and bisect useless.
23fetch or pull?
fetch downloads the remote's commits and updates your remote-tracking branches, changing nothing in your working directory. pull is fetch plus an immediate merge (or rebase) into your current branch. fetch first when you want to look before you integrate.
24Merge or rebase?
Merge preserves what actually happened: both branches stay in history and you get a merge commit joining them. Rebase replays your commits on top of the target, producing a straight line and no merge commit - but the replayed commits are NEW commits with new hashes.
The usable rule: rebase your own local feature branch onto the latest main to keep it current and to clean up your own messy commits before review; merge to integrate the finished branch. Never rebase anything someone else has pulled, because their history now contains commits that no longer exist in yours and the next merge will duplicate everything.
Follow-up What does 'never rebase a shared branch' actually break?
Rebasing replaces every commit with a copy that has a different hash and a different parent. A colleague who already pulled the originals has commits Git considers unrelated; when they pull again they get both sets, resolve the same conflicts a second time, and end up with duplicated history. On main it is a genuine incident.
25How do you resolve a merge conflict?
Git marks the file with <<<<<<< HEAD, your side, =======, their side, >>>>>>>. Open it, decide what the code should actually be - often neither side verbatim - delete the markers, git add the file to mark it resolved, then git commit for a merge or git rebase --continue for a rebase. git merge --abort or git rebase --abort backs the whole thing out.
The mistake to avoid is treating it as a text puzzle. The markers tell you two people changed the same lines; the resolution is a code decision, and it needs the tests run afterwards because a syntactically clean resolution can still be semantically wrong. git diff during a conflict, and git checkout --ours/--theirs for a whole file, are the two other commands worth knowing.
26What is git stash for?
Parking uncommitted work so you can switch context - a hotfix on another branch, a pull that refuses because of local changes. git stash push -m 'note' saves and cleans the working directory; git stash pop restores and drops the entry; git stash apply restores and keeps it.
Two things people get caught by: it ignores untracked files unless you pass -u, so a brand new file stays behind; and a stash is easy to forget about entirely. For anything you might not come back to within the hour, a throwaway commit on a branch is safer, because it is visible in the log and a stash is not.
27revert or reset - which is safe on a shared branch?
revert. It creates a NEW commit that undoes an old one, so history is only ever appended to and everyone else's clone stays valid. reset moves the branch pointer backwards and discards or unstages commits, which rewrites history and is only safe on commits nobody else has.
Follow-up What do --soft, --mixed and --hard do?
All three move the branch pointer. --soft leaves your changes staged, --mixed (the default) leaves them in the working directory unstaged, and --hard throws them away entirely. --hard is the only Git command in normal use that can genuinely lose uncommitted work - committed work is still findable in git reflog for a couple of weeks.
28How do you undo the last commit?
Three cases. Not pushed and you want to keep the changes: git reset --soft HEAD~1, which uncommits and leaves everything staged. Not pushed and you want it gone: git reset --hard HEAD~1. Already pushed to a shared branch: git revert HEAD, which adds an undoing commit and rewrites nothing.
A fourth case is worth adding unprompted, because it is the one you actually hit: if you only want to change the message or add a forgotten file, git commit --amend. It is a rewrite, so it is fine before pushing and needs a force-push after - use --force-with-lease, which refuses if the remote has moved since you last fetched.
29What does git cherry-pick do?
Takes one commit from anywhere and applies it as a NEW commit on your current branch, with a new hash. Used to pull a single fix onto a release branch without bringing the rest of the feature.
Use it sparingly: the same change now exists twice with different hashes, and when the branches eventually merge Git may or may not notice. It is the right tool for a hotfix backport and the wrong tool for 'I'll just take these six commits' - that is a branch.
30What is a pull request?
A request to merge one branch into another, hosted by the forge rather than by Git itself - it is a review surface, not a Git feature. It bundles the diff, the discussion, and whatever CI you have gated on it, and it is where the code gets read by someone else before it lands.
The reason it exists is the review, not the merge - the merge is one command. Keep them small: a 200-line PR gets read line by line and a 2,000-line PR gets approved. Write the description for a reviewer who does not know why the work started: what changed, why, and how you know it works.
31What does a good commit message look like?
A short imperative subject under about fifty characters - 'Fix null crash on empty cart', not 'fixed stuff' and not 'fixes' - then a blank line, then a body explaining WHY the change was made and what was considered instead. The diff already says what changed.
The audience is you in eight months, reading git blame on a line you do not recognise and trying to work out whether it is safe to delete. That question is answered by the why and never by the what. Imperative mood matches Git's own generated messages ('Merge branch...') and reads as 'applying this commit will...'.
32What is .gitignore, and why is my file still tracked?
It lists paths Git should not offer to track - node_modules, build output, .env, editor files. It only affects UNTRACKED files: anything already committed keeps being tracked no matter what you add to the ignore file. git rm --cached <file> untracks it while leaving it on disk.
The dangerous version of this is a committed secret. Untracking it stops future commits but the value is still in history and must be treated as leaked - rotate it. Removing it properly means rewriting history (git filter-repo) and force-pushing, and rotating is quicker and more reliable than both.
33Squash merge, merge commit, or rebase and merge?
Squash gives main one commit per PR: a clean, readable, bisectable history at the cost of losing the individual commits. A merge commit keeps every commit plus a record of the integration. Rebase-and-merge replays the commits onto main with no merge commit, giving a linear history that keeps the individual commits.
The team decision that matters more than which one: pick one and enforce it. Squash suits teams whose feature branches contain 'wip' and 'fix review comments'; merge commits suit teams whose individual commits are each meaningful and tested. Mixing them gives you a history nobody can read either way.
34How would you find which commit introduced a bug?
git bisect. Mark a known-bad commit and a known-good one and Git checks out the midpoint repeatedly; you test and say good or bad, and it finds the culprit in log2(n) steps. With a script that exits non-zero on failure, git bisect run automates the whole thing.
The other two are git log -S'someString' to find the commit that added or removed a specific string, and git blame to see who last touched a line - then git log on that commit for the why. Bisect is the one people forget exists, and it turns an afternoon of guessing into about ten checkouts.
35What is HEAD, and what is a detached HEAD?
HEAD is a pointer to your current position - normally to a branch, which in turn points at a commit. Detached HEAD means it points straight at a commit with no branch, which happens when you check out a hash or a tag. Commits you make there belong to no branch and are lost from view once you leave.
Not actually lost - git reflog lists everywhere HEAD has been, and you can branch from any of those entries for a couple of weeks. Reflog is the answer to almost every 'I destroyed my work' panic, including a bad reset --hard on committed changes.
36When is a force push acceptable?
On your own branch that nobody else has pulled, after an amend or a rebase you did deliberately - and with --force-with-lease rather than --force. Never on main or any shared branch.
--force-with-lease checks that the remote is still where you last saw it and refuses if someone pushed in the meantime. Plain --force overwrites whatever is there, which is how you delete a colleague's commits without ever seeing them. It costs one extra word and removes the only genuinely destructive failure mode.
37How do you keep a long-running feature branch current?
Rebase it onto main regularly while it is still only yours - git fetch then git rebase origin/main - so you resolve small conflicts often instead of one enormous one at the end. Once anyone else is working on the branch, merge main into it instead.
The rule underneath is the same one as before: rewriting is fine until it is shared. The real answer to a painful long-running branch is usually to stop having one - smaller PRs behind a feature flag beat a six-week branch, whatever your merge strategy.
38What is the difference between a tag and a branch?
A branch is a moving pointer that follows your commits; a tag is a fixed label on one commit, used to mark releases. Annotated tags (git tag -a v1.2.0 -m) are real objects with an author, date and message and are what you should use for releases; lightweight tags are just a name.
39How do you review someone's branch locally?
git fetch, then git checkout the branch (or git switch), then run it. For the diff alone, git diff main...branch with three dots - that compares the branch against the point where it diverged, which is what the PR shows, rather than against main's current tip.
The two-dot versus three-dot distinction is the part worth knowing: two dots compares two tips and includes changes made on main since the branch started, which makes the diff much bigger than the actual change. Three dots is the one that matches the review.
40What is CORS's relationship to your API?
If a browser on a different origin calls your API, your API has to send the Access-Control-Allow-Origin header naming that origin, and answer the preflight OPTIONS request. It is entirely a server-side configuration; nothing the front end does can grant it.
Worth adding: CORS is not authorization. Allowing an origin does not authenticate anyone, and locking it down does not protect the endpoint - anything that is not a browser ignores CORS completely. Server-to-server calls and curl never see it.
41What is the difference between authentication in an API and in a browser session?
A browser session leans on cookies, which the browser attaches automatically - convenient, and the reason CSRF exists. An API called by another service uses an explicit Authorization header, which nothing attaches for you, so there is no CSRF exposure but also no free session handling.
42How do you design an endpoint for something that is not a resource - 'send the invoice'?
Model it as a resource anyway where you can: POST /invoices/8/sends creates a send. Where that is genuinely contorted, an action endpoint - POST /invoices/8/send - is fine and everybody does it. What you do not do is smuggle it into a GET.
The reason the resource form is preferred when it fits is that it gives you somewhere to hang the result: the send has an id, a status, a timestamp and a failure reason, and can be retried and listed. An action endpoint returns 202 and leaves you with nowhere to ask 'did it work?'.
Predict the output
The output is hidden. Predict it before you open it.
fetch does not reject on 404
// GET /missing responds 404.
const res = await fetch('/missing');
console.log(res.ok, res.status);
try {
await fetch('https://no-such-host.invalid/');
} catch (e) {
console.log('threw:', e instanceof TypeError);
}Show the output
false 404
threw: truefetch only rejects when the request never completed - DNS failure, connection refused, CORS block, abort. Any HTTP response, including a 500, resolves normally. That is why every fetch wrapper needs an explicit if (!res.ok) throw, and why 'my error handling never runs' is the single most common fetch bug.
204 has no body to parse
// DELETE /items/1 responds 204 No Content.
const res = await fetch('/items/1', { method: 'DELETE' });
console.log(res.status, res.ok);
console.log(JSON.stringify(await res.text()));Show the output
204 true
""res.ok covers the whole 200-299 range, so a 204 is a success. The body is empty, so .text() gives an empty string and .json() throws on the unexpected end of input. A shared client has to branch on 204 (or on an empty content-length) before parsing.
A body can only be read once
const res = await fetch('/api/items');
console.log(res.bodyUsed);
const data = await res.json();
console.log(res.bodyUsed);
console.log(Array.isArray(data));Show the output
false
true
trueThe body is a stream, and consuming it with .json(), .text() or .blob() marks it used - a second read throws rather than returning the same data. This bites in an interceptor that logs res.text() before handing the response on. res.clone() before the first read is the way out.
What JSON.stringify drops
console.log(
JSON.stringify({ a: undefined, b: new Date(0), c: NaN, d: () => 1, e: Infinity })
);Show the output
{"b":"1970-01-01T00:00:00.000Z","c":null,"e":null}undefined values and functions are dropped from objects entirely - so a PATCH body meaning 'clear this field' with undefined sends nothing at all, and the field is untouched. NaN and Infinity become null. A Date becomes an ISO string via its toJSON, which is why dates come back as strings and need parsing on the other side. Use null when you mean 'set this to empty'.
Idempotent versus not, in one model
const db = new Map();
let nextId = 1;
const post = (body) => { const id = nextId++; db.set(id, body); return id; };
const put = (id, body) => { db.set(id, body); return id; };
post({ item: 'tea' });
post({ item: 'tea' }); // the retry
put(99, { item: 'tea' });
put(99, { item: 'tea' }); // the retry
console.log(db.size);
console.log([...db.keys()].join(','));Show the output
3
1,2,99The retried POST created a second order; the retried PUT did not, because the client chose the id and the second write is the same write. That is the whole reason a proxy may re-send a PUT and must not re-send a POST - and the reason a POST that must survive a retry needs an idempotency key to give it a client-chosen identity.
Query string encoding
const p = new URLSearchParams({ q: 'a b', tag: 'c&d' });
console.log(p.toString());
console.log(encodeURIComponent('a b&c'));
const u = new URL('https://api.example/search?a=1&a=2');
console.log(u.searchParams.get('a'));
console.log(u.searchParams.getAll('a').join('|'));Show the output
q=a+b&tag=c%26d
a%20b%26c
1
1|2URLSearchParams encodes a space as + (form encoding); encodeURIComponent uses %20. Both are valid in a query string and servers accept either, which is why the difference goes unnoticed until something compares raw strings. A repeated key is legal: .get returns the first, .getAll returns all of them.
Relative URL resolution
const base = 'https://api.example/v1/orders';
console.log(new URL('/health', base).href);
console.log(new URL('items', base).href);
console.log(new URL('items', base + '/').href);
console.log(new URL('https://other.example/x', base).href);Show the output
https://api.example/health
https://api.example/v1/items
https://api.example/v1/orders/items
https://other.example/xA leading slash resets to the root and throws away the path. Without one, resolution happens against the base's DIRECTORY - so the trailing slash on the base changes the answer completely. And an absolute URL ignores the base entirely, which is exactly how a base-URL join in an API client turns into an SSRF if the path comes from user input.
Headers are case-insensitive and can repeat
const h = new Headers({ 'Content-Type': 'application/json' });
console.log(h.get('content-type'));
console.log(h.get('X-Missing'));
h.append('x-trace', '1');
h.append('x-trace', '2');
console.log(h.get('x-trace'));Show the output
application/json
null
1, 2Header names are case-insensitive everywhere in HTTP, so matching on the exact case you sent is a bug waiting for a proxy to normalise it. A missing header is null, not undefined. append accumulates while set replaces, and repeated values come back joined with a comma and a space.
Aborting a request
const ac = new AbortController();
const p = fetch('/slow', { signal: ac.signal });
ac.abort();
try {
await p;
} catch (e) {
console.log(e.name);
}Show the output
AbortErrorAn aborted fetch rejects with a DOMException whose name is AbortError, not with a network TypeError - so error handling has to tell them apart or a cancelled request looks like a failure and shows an error toast. This is the mechanism behind request cancellation on unmount and behind a client-side timeout, since fetch has no timeout option of its own.
Retry-worthy or not
const retryable = (status) => status === 429 || (status >= 500 && status < 600);
console.log([400, 401, 404, 409, 422, 429, 500, 502, 503].filter(retryable).join(','));Show the output
429,500,502,503Nothing in the 4xx range is worth retrying except 429, because a 4xx means the request itself was wrong and sending it again unchanged produces the same answer. 429 is the exception: the request was fine, you were just too fast, so back off and repeat. Retrying a 409 in a loop is a common bug - a conflict needs the client to re-read and re-decide, not to try harder.
Backoff with jitter
const delay = (attempt) => Math.min(1000 * 2 ** attempt, 30000);
console.log([0, 1, 2, 3, 4, 5, 6].map(delay).join(','));Show the output
1000,2000,4000,8000,16000,30000,30000Exponential growth with a ceiling, so a long outage does not produce a two-hour wait. What this version is missing is jitter - every client that failed in the same second retries in the same second, which recreates the load spike that caused the failure. Multiply by a random factor (or pick uniformly in [0, delay]) before using it.
Bearer token, decoded
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.sig';
const payload = JSON.parse(atob(token.split('.')[1]));
console.log(payload.sub, payload.role);Show the output
123 adminThe payload is base64url, not encryption - anyone holding the token reads every claim with one line and no key, which is why nothing secret goes inside a JWT. Note what this code did NOT do: verify the signature. Decoding is safe for showing a username in the UI; trusting a claim like role without verifying server-side is how privilege escalation happens.
Write it from memory
Complete, runnable code. Practice typing it from memory before your interview.
A fetch client with timeout, retries and idempotency
The wrapper every app writes eventually. It throws on non-2xx, times out, retries only what is safe to retry, and gives non-idempotent writes a key so the retry cannot duplicate them.
class HttpError extends Error {
constructor(status, body) {
super('HTTP ' + status);
this.name = 'HttpError';
this.status = status;
this.body = body;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const retryable = (s) => s === 429 || (s >= 500 && s < 600);
async function request(url, options = {}) {
const {
method = 'GET',
body,
headers = {},
timeoutMs = 10000,
retries = 3,
idempotencyKey,
} = options;
const safeToRetry =
['GET', 'HEAD', 'PUT', 'DELETE'].includes(method) || Boolean(idempotencyKey);
for (let attempt = 0; ; attempt++) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
method,
signal: ac.signal,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
if (safeToRetry && retryable(res.status) && attempt < retries) {
const after = Number(res.headers.get('Retry-After'));
await sleep(after ? after * 1000 : backoff(attempt));
continue;
}
throw new HttpError(res.status, await safeBody(res));
}
if (res.status === 204) return null;
return await res.json();
} catch (err) {
const isNetwork = err instanceof TypeError;
const isTimeout = err.name === 'AbortError';
if (safeToRetry && (isNetwork || isTimeout) && attempt < retries) {
await sleep(backoff(attempt));
continue;
}
throw err;
} finally {
clearTimeout(timer);
}
}
}
function backoff(attempt) {
const ceiling = Math.min(1000 * 2 ** attempt, 30000);
return Math.random() * ceiling; // full jitter
}
async function safeBody(res) {
try {
return await res.json();
} catch {
return null;
}
}
// Usage
const items = await request('/api/items');
await request('/api/orders', {
method: 'POST',
body: { item: 'tea', qty: 2 },
idempotencyKey: crypto.randomUUID(),
});- The retry gate is the point of the whole thing: a bare POST is never retried, and a POST carrying an idempotency key is.
- Generate the key once per logical operation, outside any retry loop - a fresh key per attempt defeats it entirely.
- Retry-After is honoured when the server sends one; the backoff is only the fallback, and it has jitter so a fleet of clients does not retry in lockstep.
- AbortController is the only way to time a fetch out, and the AbortError has to be told apart from a real network TypeError.
- 204 returns null rather than calling .json() on an empty body, which would throw.