Backbone.js Interview Questions and Answers
Last updated:
Check out 35 of the most common Backbone.js interview questions, then take an AI-powered practice interview
Q1Which objects does Backbone actually ship, and what is deliberately missing compared with a full framework?
BasicFundamentals
Answer
Backbone ships seven things. Backbone.Model holds an attributes hash and emits change events. Backbone.Collection is an ordered set of models with a comparator and about forty-six proxied Underscore methods.
Backbone.View is a thin wrapper around a single DOM element with a delegated events hash. Backbone.Router maps URL fragments to callbacks. Backbone.history is the singleton that listens to popstate or hashchange and drives the routers.
Backbone.Events is a publish and subscribe mixin that everything else is built on. Backbone.sync is the single function that turns model persistence into HTTP calls. That is the whole surface area.
What is deliberately absent matters more in interviews: there is no virtual DOM and no diffing, so render() typically blows away this.el.innerHTML and rebuilds it. There is no templating engine, Backbone leans on Underscore's _.template. There is no two-way data binding, you wire model change events to render yourself.
There is no subview registry, so parent views must track and destroy children manually or you leak memory. There is no dependency injection, no build tooling, no state container and no opinion on folder layout. Backbone's own documentation calls this a feature, and it genuinely was in 2010 when the alternative was a jQuery spaghetti file. In 2026 the honest framing for an interviewer is that Backbone gives you a vocabulary and leaves the framework for you to build, which is why every large Backbone app eventually grows its own conventions or adopts Marionette.
Key Points
- Model, Collection, View, Router, history, Events, sync: that is the whole library
- No virtual DOM, no diffing, render() usually rebuilds the element
- No templating engine of its own, it uses Underscore's _.template
- No subview management, which is the root cause of most memory leaks
- Backbone.Events is the substrate everything else inherits from
Q2Why is Underscore.js a hard dependency of Backbone while jQuery is optional?
BasicDependencies
Answer
Backbone's internals are written against Underscore, not merely bundled with it. _.extend implements the class-extension mechanism behind Model.extend and View.extend. _.defaults applies model defaults. _.isEqual is what Model#set uses to decide whether an attribute actually changed, which is why setting a deep object to a structurally identical object fires no change event. _.result is how url, urlRoot, className and tagName can each be either a value or a function. _.uniqueId generates cids. On top of that, Backbone.Collection proxies roughly forty-six Underscore methods (each, map, filter, find, reduce, sortBy, groupBy, countBy, pluck, invoke, partition, and so on) directly onto collection instances, and Model proxies a smaller set (keys, values, pick, omit, chain, isEmpty). Remove Underscore and Backbone does not load at all. jQuery is different.
It is reached only through the Backbone.$ property, and only in two places: Backbone.View DOM work (this.$el, this.$(), setElement, remove) and Backbone.sync, which calls Backbone.ajax, which by default calls Backbone.$.ajax. If you never instantiate a View and you replace Backbone.sync with a fetch-based implementation, Backbone runs with Backbone.$ unset. That is the standard route for teams stripping jQuery out of a legacy bundle.
In practice most shops set Backbone.$ explicitly at bootstrap rather than relying on the global window.jQuery, because bundlers do not create globals. Lodash can substitute for Underscore in many apps, but only with care, since a few Underscore behaviours differ.
// bootstrap.js: wire dependencies explicitly instead of relying on globals
import $ from 'jquery';
import _ from 'underscore';
import Backbone from 'backbone';
Backbone.$ = $; // required for View DOM work and default sync
// Collection proxies Underscore methods straight onto the instance
const tasks = new Backbone.Collection([
{ id: 1, title: 'Refund audit', done: false, owner: 'priya' },
{ id: 2, title: 'KYC retry', done: true, owner: 'arjun' },
]);
tasks.filter((m) => !m.get('done')); // -> [Model]
tasks.pluck('title'); // -> ['Refund audit', 'KYC retry']
tasks.groupBy((m) => m.get('owner')); // -> { priya: [...], arjun: [...] }
// _.isEqual decides whether set() fires a change event
const m = tasks.at(0);
m.on('change:tags', () => console.log('fired'));
m.set('tags', ['a']);
m.set('tags', ['a']); // structurally equal, no event
Q3How do you define defaults on a Backbone.Model, and why must defaults be a function when it contains an object or array?
BasicModels
Answer
defaults can be a plain object or a function returning an object. Backbone applies it inside the Model constructor using _.defaults(attrs, _.result(this, 'defaults')), so it only fills keys that were not supplied. The trap is that when defaults is a plain object literal, that literal lives on the prototype and every instance receives the same object reference for any non-primitive value.
Push one item into a model's default tags array and every other model created from that class, including ones created earlier, sees the mutation, because they all point at one array. This produces the classic bug report where a user adds a tag to one row in a grid and the tag appears on every row. Making defaults a function means the object literal is evaluated afresh per instance, so each model gets its own array.
Primitives are safe either way because assignment replaces rather than mutates them, but the safe habit is to always use the function form. A second detail interviewers like: defaults do not deep merge. If the default is { prefs: { theme: 'light', density: 'compact' } } and you construct the model with { prefs: { theme: 'dark' } }, the whole prefs object is replaced and density disappears.
Nested defaults have to be merged in initialize or parse. Third, defaults run only through the constructor, so calling model.clear() wipes attributes without restoring defaults unless you re-set them yourself.
const Candidate = Backbone.Model.extend({
// WRONG: tags array is shared across every instance
// defaults: { name: '', tags: [], prefs: { remote: false } },
// RIGHT: fresh objects per instance
defaults() {
return { name: '', tags: [], prefs: { remote: false } };
},
initialize(attrs = {}) {
// defaults do NOT deep merge, do it yourself
this.set('prefs', { ...this.defaults().prefs, ...(attrs.prefs || {}) }, { silent: true });
},
});
const a = new Candidate();
const b = new Candidate({ prefs: { remote: true } });
a.get('tags').push('java');
console.log(b.get('tags')); // [] with the function form
console.log(b.get('prefs')); // { remote: true }
// clear() removes attributes, it does not restore defaults
a.clear();
console.log(a.get('name')); // undefined
Q4What is the difference between model.get(), model.set(), model.escape() and model.toJSON()?
BasicModels
Answer
get(key) reads a single attribute out of the internal attributes hash. Attributes are never plain properties on the model, so model.name is undefined while model.get('name') works, and this trips up people arriving from plain objects or from React state. set(key, value) or set({a: 1, b: 2}) writes attributes, runs validation if you pass {validate: true}, records what changed, and fires change:a, change:b and then a single change event. escape(key) returns the attribute run through _.escape, so ampersands, angle brackets and quotes become HTML entities. It exists because Underscore's default <%= %> interpolation does not escape, and escape() is a cheap way to avoid cross-site scripting when you inject user-controlled strings into a template. toJSON() is the one people misread.
Despite the name it does not produce a string, it returns _.clone(this.attributes), a shallow copy. Shallow is the whole story: any nested object or array is shared by reference between the model and whatever you passed toJSON's output into. Mutate model.toJSON().prefs.theme and you have silently mutated the model without firing a single change event, which is a real production bug when someone hands toJSON output to a template that sorts an array in place. toJSON is also what JSON.stringify calls, so it is the payload Backbone.sync sends to the server. Override it when your API shape differs from your client shape, for example to drop UI-only attributes such as _selected or _expanded before a PUT.
const job = new Backbone.Model({
title: 'Senior Frontend <Backbone>',
skills: ['backbone', 'jquery'],
_selected: true, // UI-only flag
});
job.title; // undefined, attributes are not properties
job.get('title'); // 'Senior Frontend <Backbone>'
job.escape('title'); // 'Senior Frontend <Backbone>'
// toJSON is a SHALLOW clone: nested refs are shared
const snap = job.toJSON();
snap.skills.push('react');
job.get('skills'); // ['backbone','jquery','react'] and no change event fired
// Strip UI-only attributes from what sync sends
const Job = Backbone.Model.extend({
toJSON() {
return _.omit(Backbone.Model.prototype.toJSON.call(this), '_selected', '_expanded');
},
});
Key Points
- Attributes live in model.attributes, never as direct properties
- escape() applies _.escape for safe interpolation into templates
- toJSON() returns a shallow clone, nested objects stay shared by reference
- toJSON() is the wire payload, override it to strip UI-only attributes
Q5What is a model's cid, and when do you use it instead of the id?
BasicModels
Answer
Every Backbone model gets a client id the moment it is constructed: a string like c1, c2, c14 generated by _.uniqueId with the cidPrefix, which defaults to 'c'. It is unique per page load, never sent to the server, and never reused. The id, by contrast, comes from the server and is read from whichever attribute idAttribute names, 'id' by default, or '_id' for a Mongo-backed API.
A model that has no id is new, and model.isNew() is literally a check for the absence of the idAttribute value, which is what makes save() choose POST instead of PUT. The cid matters because you frequently need a stable handle on a model before the server has ever seen it. A user clicks add row, you push an unsaved model into a collection and render it, and the DOM node needs an identifier: use the cid. collection.get() accepts an id, a cid or a model object, so collection.get('c3') resolves the unsaved row while collection.get(42) resolves a persisted one.
View instances also carry their own cid, and Backbone uses it internally to namespace delegated jQuery events as '.delegateEvents' plus the view cid, which is how undelegateEvents can unbind one view's handlers without touching another's. The gotcha to name in an interview: once the server responds and the id lands on the model, the cid does not change, but any DOM attribute you wrote from the id is now stale. Either key your DOM off cid consistently, or re-render on the sync event.
const Applicant = Backbone.Model.extend({ idAttribute: '_id' }); // Mongo-style API
const list = new Backbone.Collection([], { model: Applicant });
const draft = list.add({ name: 'Neha' });
draft.cid; // 'c1'
draft.id; // undefined
draft.isNew(); // true -> save() will POST
list.get(draft.cid); // works before the server knows about it
list.get(draft); // also works
draft.save(null, {
success() {
draft.id; // now set from the _id in the response
draft.isNew(); // false -> next save() sends PUT
draft.cid; // still 'c1', unchanged
},
});
// Key DOM nodes off cid so unsaved and saved rows behave identically
const RowView = Backbone.View.extend({
tagName: 'tr',
attributes() {
return { 'data-cid': this.model.cid };
},
});
Q6How does the events hash on a Backbone.View work, and what are its real limits?
BasicViews
Answer
The events hash maps a string of the form 'eventName selector' to a method name or a function. During construction, Backbone calls delegateEvents(), which iterates the hash and calls this.$el.on(eventName + '.delegateEvents' + this.cid, selector, boundHandler). Two consequences follow directly from that implementation.
First, these are jQuery delegated handlers bound on this.el, not on the matched children, so elements added to the view later are picked up automatically with no rebinding. That is why you can call this.$el.html(template) inside render() and your buttons still work. Second, the namespace means undelegateEvents() can strip exactly this view's handlers and leave any other jQuery bindings on the same element alone.
The limits are what interviewers ask about. Events fired on nodes outside this.el never reach the hash, so a modal appended to document.body escapes the view and needs its own view or an explicit binding you clean up. An empty selector, written as just 'click', binds to this.el itself. focus and blur do not bubble, so 'focus input' silently never fires and you must use focusin and focusout instead.
If you replace this.el directly rather than through setElement(), the delegated handlers stay attached to the orphaned old node and the new one is dead. And an events hash defined as an object literal is evaluated once at extend time; use the function form when keys depend on instance state.
const CandidateRow = Backbone.View.extend({
tagName: 'tr',
events: {
'click .js-shortlist': 'shortlist',
'focusin input.notes': 'onFocus', // focus does NOT bubble, focusin does
'keyup input.notes': 'onKeyup',
'click': 'onRowClick', // empty selector binds to this.el
},
shortlist(e) {
e.stopPropagation(); // otherwise onRowClick also fires
this.model.save({ status: 'shortlisted' }, { patch: true });
},
onKeyup: _.debounce(function (e) {
this.model.set('notes', e.currentTarget.value);
}, 250),
render() {
// delegated handlers survive this, nothing to rebind
this.$el.html(this.template(this.model.toJSON()));
return this;
},
});
// Dynamic hash when keys depend on instance state
const Toggle = Backbone.View.extend({
events() {
return this.readOnly ? {} : { 'click .js-edit': 'edit' };
},
});
Key Points
- Handlers are jQuery delegated bindings on this.el, namespaced with the view cid
- Children rendered later are handled automatically, no rebinding needed
- focus and blur do not bubble, use focusin and focusout
- Nodes outside this.el, such as a body-level modal, are unreachable
- Use the function form of events when keys depend on instance state
Q7Explain el, $el, tagName, className, id and attributes on a view, and what setElement() does.
BasicViews
Answer
Every view owns exactly one DOM element. If you pass el when constructing, either a selector string, a DOM node or a jQuery object, the view attaches to that existing element. If you do not, Backbone's _ensureElement creates a new detached element from tagName (default 'div'), then applies className, id and the attributes hash.
Each of those can be a value or a function, because Backbone reads them through _.result. $el is the jQuery-wrapped cache of el, and this.$(selector) is shorthand for this.$el.find(selector), which scopes a lookup to the view instead of searching the whole document. The distinction that matters in production: attaching to an existing element makes the view a passenger, it does not own the node and calling remove() will delete markup it did not create. Creating its own element makes the view portable, the parent decides where to insert this.el.
The portable form is almost always the better default, with exactly one root view attached to a pre-existing container. setElement(element) is the supported way to swap the underlying node. It calls undelegateEvents() on the old element, replaces el and $el, then calls delegateEvents() again on the new one. Assigning this.el = node by hand skips both steps, which leaves handlers bound to a node no longer in the document and a new node with no handlers at all, a bug that presents as buttons that stop working only after some other feature re-renders the page.
const Panel = Backbone.View.extend({
tagName: 'section',
className: 'panel panel--compact',
id() {
return 'panel-' + this.model.cid; // functions are resolved via _.result
},
attributes: { role: 'region', 'aria-live': 'polite' },
events: { 'click .js-close': 'close' },
render() {
this.$el.html('<button class="js-close">x</button>');
return this;
},
close() {
this.remove(); // safe: the view created this node
},
});
// Parent decides placement, the view stays portable
const panel = new Panel({ model });
document.querySelector('#app').appendChild(panel.render().el);
// Swapping the node: use setElement, never this.el = node
panel.setElement(document.querySelector('#replacement')); // re-delegates events
Q8How do you render a Backbone view with an Underscore template, and what breaks under a strict Content-Security-Policy?
BasicTemplates
Answer
_.template(source) compiles a template string into a function you call with a data object. Three delimiters matter: <%= value %> interpolates raw, <%- value %> interpolates HTML-escaped, and <% code %> executes JavaScript for loops and conditionals. The default interpolation is unescaped, so any template that prints user input with <%= %> is a stored cross-site scripting hole.
Use <%- %> by default and reach for <%= %> only for markup you generated yourself. Compilation is expensive relative to execution, so compile once at extend time or at build time, never inside render(). The idiomatic pattern is template: _.template($('#tpl-row').html()) evaluated when the module loads, then this.$el.html(this.template(this.model.toJSON())) inside render(), returning this so calls can chain.
Two production issues come up constantly. First, _.template builds the function with new Function, which requires 'unsafe-eval' in your Content-Security-Policy. Tightening CSP on a legacy Backbone app breaks every runtime-compiled template at once, and the fix is to precompile templates at build time into plain functions.
Second, the default delimiters clash with server-side template engines that also use <% %>, notably ERB in Rails and JSP. Change them globally through _.templateSettings, for instance to {{ }} style, before any template is compiled. Passing {variable: 'data'} to _.template is also worth knowing: it stops Underscore emitting a with block, which is both faster and required in strict-mode-only contexts.
// Change delimiters once, before any template compiles (avoids ERB/JSP clashes)
_.templateSettings = {
interpolate: /\{\{=(.+?)\}\}/g, // raw
escape: /\{\{-(.+?)\}\}/g, // escaped
evaluate: /\{\{(.+?)\}\}/g, // logic
};
const JobCard = Backbone.View.extend({
className: 'job-card',
// compiled ONCE at extend time, not per render
template: _.template(
'<h3>{{- job.title }}</h3>' +
'<p>{{- job.company }} | {{- job.city }}</p>' +
'{{ if (job.urgent) { }}<span class="tag">Urgent</span>{{ } }}',
{ variable: 'job' } // no with() block: faster and strict-mode safe
),
render() {
this.$el.html(this.template(this.model.toJSON()));
return this; // enables view.render().el
},
});
Key Points
- <%= %> is raw and unsafe, <%- %> escapes, <% %> executes logic
- Compile at extend or build time, never inside render()
- _.template uses new Function, so it needs CSP 'unsafe-eval' at runtime
- Override _.templateSettings when server templates also use <% %>
- render() should return this so callers can chain .render().el
Q9What does Backbone.Collection give you that a plain array of models does not?
BasicCollections
Answer
Five concrete things. First, identity lookup: the collection maintains an internal _byId index keyed by both id and cid, so collection.get(42) and collection.get('c7') are constant-time rather than a linear scan. Second, ordering: set a comparator and models are inserted in sorted position on add, sort() re-sorts and fires a sort event, and at(index) reads positionally.
Third, event bubbling: every event fired by a contained model is re-fired on the collection, so a single listener on the collection catches change:status from any of a thousand rows, and the collection adds its own add, remove, reset, sort, update, request, sync and error events. Fourth, persistence: url plus fetch() populates it from an endpoint, and create() instantiates a model, adds it and saves it in one call. Fifth, the Underscore proxies, roughly forty-six methods including map, filter, find, reduce, sortBy, groupBy, countBy, pluck, invoke, partition and each, all available directly on the instance.
On top of those, where({status: 'active'}) and findWhere() give attribute matching without writing a predicate. The behaviour that surprises people: collection.length is a real number maintained by Backbone, but the collection is not an array, so array methods that are not proxied, such as flatMap or the spread operator, do not work on it directly. Use collection.models when you genuinely need the array, and remember that mutating that array in place bypasses every index and event the collection maintains.
const Applications = Backbone.Collection.extend({
url: '/api/applications',
comparator: 'appliedAt', // string form sorts ascending by attribute
active() {
return this.where({ status: 'active' });
},
});
const apps = new Applications();
// One listener catches change events from every contained model
apps.on('change:status', (model, value) => {
console.log(model.id, 'moved to', value);
});
apps.on('update', ({ changes }) => {
console.log(changes.added.length, 'added', changes.removed.length, 'removed');
});
apps.fetch();
apps.get(1042); // O(1) via the internal _byId index
apps.findWhere({ email: 'x@y.in' });
apps.countBy('status'); // proxied Underscore method
apps.models; // the raw array, mutate it and you break the index
Q10How does a Backbone.Model work out the URL it should hit, and how do urlRoot and the parent collection interact?
BasicPersistence
Answer
Model#url() resolves a base in a fixed order: _.result(this, 'urlRoot') first, then _.result(this.collection, 'url'), and if neither exists it throws the classic 'A url property or function must be specified' error. Once it has a base, a new model, meaning isNew() is true, uses the base unchanged, which is where POST goes. A persisted model appends a slash if the base does not already end in one, then encodeURIComponent(this.get(this.idAttribute)).
The encoding step matters for ids that are emails or slugs with spaces. So a model with urlRoot '/api/candidates' and id 88 hits /api/candidates/88 for PUT, PATCH, GET and DELETE, and /api/candidates for the initial POST. If the model is inside a collection with url '/api/jobs/12/candidates' and has no urlRoot of its own, it inherits that base and hits /api/jobs/12/candidates/88, which is how you get nested REST routes for free.
Both url and urlRoot can be functions, resolved through _.result at call time, so they can depend on the current tenant, locale or a parent model's id. Two gotchas worth naming. A model removed from its collection loses that inherited base, so a destroy() issued after a remove() throws the url error, and the fix is to set urlRoot explicitly on the model class. And overriding url() entirely is legitimate when your API is not RESTful, for example an RPC-style endpoint, but then you also need to think about what method Backbone.sync will pick.
const Candidate = Backbone.Model.extend({
urlRoot: '/api/candidates',
idAttribute: '_id',
});
new Candidate().url(); // '/api/candidates' -> POST
new Candidate({ _id: 88 }).url(); // '/api/candidates/88' -> PUT/GET/DELETE
new Candidate({ _id: 'a@b.in' }).url();// '/api/candidates/a%40b.in'
// Nested routes come free from the parent collection
const JobCandidates = Backbone.Collection.extend({
model: Backbone.Model.extend({}), // no urlRoot: inherits from the collection
initialize(models, opts) { this.jobId = opts.jobId; },
url() { return '/api/jobs/' + this.jobId + '/candidates'; },
});
const c = new JobCandidates([{ id: 5 }], { jobId: 12 }).at(0);
c.url(); // '/api/jobs/12/candidates/5'
// Gotcha: remove() strips model.collection, so this then throws
// collection.remove(c); c.destroy(); -> 'A url property or function must be specified'
Key Points
- Resolution order is urlRoot, then collection.url, then throw
- isNew() decides whether the id segment is appended
- The id is passed through encodeURIComponent
- Both url and urlRoot may be functions, resolved lazily via _.result
- Removing a model from its collection breaks an inherited url
Q11How do Backbone.Router route patterns work, including :param, *splat and optional segments?
BasicRouting
Answer
A router extends Backbone.Router with a routes hash mapping a fragment pattern to a method name on the router. Backbone compiles each pattern into a regular expression through _routeToRegExp. A colon parameter such as :id matches a single URL segment, stopping at the next slash, question mark or hash, and is passed to the handler as an argument.
A splat, written *path, is greedy and matches everything remaining including slashes, which is why it must come last and why it is used for catch-all 404 routes and for file-path style URLs. Parentheses mark an optional group, so 'jobs/:id(/:tab)' matches both jobs/12 and jobs/12/applicants, and when the optional part is absent the handler receives null for that argument, not undefined and not an empty string, which matters when you write a default. Route order is significant in a subtle way: Backbone.history stores routes in reverse insertion order and stops at the first regex that matches, so the last route declared in the hash is tested first.
Put the catch-all splat first in the object literal so it ends up tested last. Query strings are not parsed for you, they arrive as a trailing argument only if the pattern captures them, so most apps either use a splat or parse location.search by hand. You can also call this.route(regExp, name, callback) to register a raw regular expression when the string DSL is not expressive enough, and every matched route fires a route:name event on the router plus a route event on Backbone.history.
const AppRouter = Backbone.Router.extend({
routes: {
'*notFound': 'notFound', // declared FIRST so it is tested LAST
'': 'home',
'jobs': 'jobList',
'jobs/:id(/:tab)': 'jobDetail', // optional group -> null when absent
'search/*query': 'search', // greedy splat, must be last in its family
},
home() { /* ... */ },
jobList() { /* ... */ },
jobDetail(id, tab) {
// '#jobs/12' -> id '12', tab null
// '#jobs/12/applicants' -> id '12', tab 'applicants'
this.show(id, tab || 'overview');
},
search(query) { /* '#search/react/bengaluru' -> 'react/bengaluru' */ },
notFound(path) { console.warn('no route for', path); },
});
const router = new AppRouter();
router.on('route:jobDetail', (id, tab) => analytics.page('job', { id, tab }));
Backbone.history.start();
Q12What does Backbone.history.start() actually do, and why must it be called exactly once?
BasicRouting
Answer
Backbone.history is a singleton, the only instance of Backbone.History, and it is the component that owns the browser. Routers do not listen to the URL themselves, they register their compiled patterns with history and history does the listening. start() binds a popstate listener when pushState is enabled, or a hashchange listener otherwise, computes the current fragment, and dispatches it to the first matching route. Because it is a singleton, calling start() twice throws 'Backbone.history has already been started'.
In a single-page app that is easy to satisfy, but it bites in two situations: hot module reload during development, and test suites that construct the app per test. The fix in both cases is to guard with if (Backbone.History.started) Backbone.history.stop() before starting again. Three options matter. pushState: true switches from hash fragments to real paths and requires the server to serve your index HTML for every route, otherwise a refresh on /jobs/12 returns a 404. root: '/app/' tells history which prefix to strip when the app is not served from the domain root, and forgetting it produces routes that never match. silent: true starts history without firing the initial route, which is what you want when the server already rendered the first screen and you only need client routing from the next navigation onward. start() returns a boolean telling you whether any route matched the initial fragment, useful for a hard redirect to a login screen when nothing did.
const router = new AppRouter();
// Guard makes this safe under hot reload and in test setup
if (Backbone.History.started) Backbone.history.stop();
const matched = Backbone.history.start({
pushState: true, // real URLs; server must rewrite all paths to index.html
root: '/app/', // strip this prefix before matching
hashChange: true, // fallback when pushState is unavailable
silent: false, // set true if the server already rendered the first screen
});
if (!matched) router.navigate('', { trigger: true, replace: true });
// navigate() updates the URL; { trigger: true } also runs the handler
router.navigate('jobs/12/applicants', { trigger: true });
// { replace: true } rewrites history instead of pushing, good after a redirect
router.navigate('login', { trigger: true, replace: true });
Key Points
- Backbone.history is a singleton; routers only register patterns with it
- Calling start() twice throws, guard with Backbone.History.started
- pushState: true requires a server catch-all rewrite to index.html
- root strips a path prefix when the app is not at the domain root
- navigate() only changes the URL unless you pass trigger: true
Q13How do you mix Backbone.Events into an arbitrary object, and what do trigger, once and the 'all' event give you?
BasicEvents
Answer
Backbone.Events is a plain object of methods, so _.extend(target, Backbone.Events) turns any object into an event emitter. That is not a trick, it is how Model, Collection, View, Router and history all get their event behaviour internally. The API is on, off, once, trigger, listenTo, listenToOnce and stopListening. on(name, callback, context) subscribes, and the third argument sets this inside the callback, which saves a bind. trigger(name, ...args) fires synchronously, in subscription order, on the same tick, so a handler that throws stops the remaining handlers from running, something to remember before putting risky work in a listener. once() auto-unsubscribes after the first fire. off() with no arguments removes every listener on the object, off('change') removes all handlers for that event, and off('change', fn) removes one.
Event names are just strings, and the convention of a colon, as in 'change:status' or 'route:jobDetail', is only a convention, Backbone does not implement jQuery-style namespaces. Two extras are worth knowing. You can pass a map, on({ add: onAdd, remove: onRemove }), and you can subscribe to multiple names separated by spaces, on('add remove', onChange). The special 'all' event fires for every event on the object, with the event name as the first argument, which makes it a good hook for a debug logger or for proxying events from one object to another, but a bad thing to leave in a hot path because it runs on every single trigger.
// Any object can be an emitter
const appBus = _.extend({}, Backbone.Events);
appBus.on('session:expired', () => router.navigate('login', { trigger: true }));
appBus.once('boot:done', () => console.log('runs exactly once'));
appBus.on('add remove', onListChanged); // space-separated names
appBus.on({ save: onSave, cancel: onCancel }); // event map form
appBus.trigger('session:expired');
// 'all' catches every event, name arrives as the first argument
if (process.env.NODE_ENV !== 'production') {
appBus.on('all', (name, ...args) => console.debug('[bus]', name, args));
}
// Proxy a child collection's events onto a parent object
const proxy = (from, to, prefix) =>
to.listenTo(from, 'all', (name, ...args) => to.trigger(prefix + ':' + name, ...args));
appBus.off(); // remove every listener on the object
Q14What is the difference between initialize, the constructor, and preinitialize on a Backbone class?
BasicFundamentals
Answer
Backbone's extend gives you prototypal subclassing with three hooks. The constructor is the real one, and Backbone defines it for you on Model, Collection, View and Router. If you override it you take responsibility for calling the parent, typically Backbone.Model.apply(this, arguments), and if you forget, attributes are never set, the cid is never assigned and nothing works. initialize is the hook you are meant to use.
Backbone's own constructor calls this.initialize.apply(this, arguments) as its last step, after attributes are set, defaults applied, the cid assigned, the element ensured and events delegated. For a Model, initialize receives (attributes, options); for a Collection, (models, options); for a View, (options); for a Router, (options). preinitialize, added in the Backbone 1.4 line, is the mirror image: it runs at the very start of the constructor, before any of that setup. It exists for the rare case where you need to modify the incoming options or swap a property before Backbone reads it, for example choosing a tagName based on a passed-in flag, since by the time initialize runs the element already exists.
The practical rule for interviews: use initialize for wiring listeners and derived state, use preinitialize only when you must influence construction itself, and override the constructor essentially never. One detail that catches people: View options are no longer auto-assigned onto the instance in modern Backbone, so this.foo is undefined unless you assign it yourself in initialize.
const RowView = Backbone.View.extend({
// runs BEFORE the element is created, can still change tagName
preinitialize(options = {}) {
this.tagName = options.inline ? 'span' : 'div';
},
// runs AFTER el, $el, cid and delegated events exist
initialize(options = {}) {
this.readOnly = options.readOnly || false; // options are NOT auto-assigned
this.listenTo(this.model, 'change:status', this.render);
this.listenTo(this.model, 'destroy', this.remove);
},
render() {
this.$el.text(this.model.get('status'));
return this;
},
});
// Overriding the constructor: you MUST call through, or nothing is initialised
const Timestamped = Backbone.Model.extend({
constructor(attrs, options) {
Backbone.Model.apply(this, [{ createdAt: Date.now(), ...attrs }, options]);
},
});
Key Points
- initialize runs last in the constructor, after cid, defaults and el exist
- preinitialize (Backbone 1.4+) runs first, before any setup
- Overriding the constructor requires calling Backbone.X.apply(this, arguments)
- View options are not auto-assigned onto the instance, copy them in initialize
Q15Why does every Backbone code review insist on this.listenTo(model, ...) instead of model.on(...)?
IntermediateEvents
Answer
Because of who owns the subscription and therefore who can clean it up. model.on(event, handler, this) records the handler in the model's own _events registry. The model now holds a reference to the view's method and to the view as context, so the model keeps the view alive in memory for as long as the model exists. To unsubscribe you need both the exact same function reference and access to the model, which is precisely what you no longer have once the view has been thrown away. this.listenTo(model, event, handler) inverts the relationship.
The view registers the subscription in its own _listeningTo map as well as on the model, so the view can later call this.stopListening() with no arguments and drop every subscription it ever created, across every object it listened to, in one call. That single line is the difference between a Backbone app that runs for eight hours in a support agent's browser and one that has to be refreshed twice a shift. The payoff is automatic: Backbone.View#remove() calls this.stopListening() for you before removing the element from the DOM, so a view built entirely with listenTo cleans itself up correctly with no bookkeeping.
Mix in one model.on() call and that view leaks forever. listenToOnce exists for the fire-once case. The only legitimate uses of on() are subscribing to an object you own and will destroy yourself, and global one-time bootstrap wiring.
const LeakyView = Backbone.View.extend({
initialize() {
// the collection now holds a reference to this view, forever
this.collection.on('sync', this.render, this);
},
});
const SafeView = Backbone.View.extend({
initialize() {
this.listenTo(this.collection, 'sync', this.render);
this.listenTo(this.collection, 'add remove', this.renderCount);
this.listenToOnce(this.collection, 'sync', this.hideSpinner);
},
render() { return this; },
renderCount() {},
hideSpinner() {},
});
const v = new SafeView({ collection: apps });
v.remove(); // View#remove() calls stopListening() then $el.remove()
// Both views were removed from the DOM; only one is actually collectable
apps.trigger('sync'); // LeakyView.render still runs on a detached element
Q16What is a zombie view in Backbone, how do you detect one, and how do you prevent it?
IntermediateMemory Management
Answer
A zombie view is a view whose DOM element has been removed from the page but whose event subscriptions are still live, so it keeps responding to model and collection events, keeps re-rendering a detached node, and keeps itself and its model graph out of reach of the garbage collector. Three things create zombies. Using model.on() instead of listenTo, so the model holds the reference.
Emptying a container with this.$el.html('') or .empty() instead of calling remove() on each child view, which deletes the markup but leaves every child object subscribed. And re-running a render() that constructs fresh subviews without destroying the previous batch, which is the most common one, because it looks harmless and only shows up after the user has clicked the same tab twenty times. The symptoms are recognisable: an event handler fires N times instead of once where N is the number of times the screen has been opened, CPU climbs steadily during a long session, and Chrome's memory timeline shows detached HTMLDivElement counts rising and never falling.
Detection is straightforward with the heap snapshot tool, filter by Detached and look for elements retained by a Backbone view. The fix is a discipline, not an API: every parent view keeps an array or map of its children, exposes a removeChildren() that calls remove() on each and empties the list, and calls that at the top of render() and inside its own remove() override. Marionette exists largely because this bookkeeping is mechanical enough to be a library.
const ListView = Backbone.View.extend({
initialize() {
this.children = [];
this.listenTo(this.collection, 'reset sync', this.render);
},
removeChildren() {
this.children.forEach((v) => v.remove()); // remove() -> stopListening() + $el.remove()
this.children.length = 0;
},
render() {
this.removeChildren(); // ALWAYS before rebuilding
const frag = document.createDocumentFragment();
this.collection.each((model) => {
const row = new RowView({ model });
this.children.push(row);
frag.appendChild(row.render().el);
});
this.$el.empty().append(frag);
return this;
},
// Backbone's remove() does not know about children, so extend it
remove() {
this.removeChildren();
return Backbone.View.prototype.remove.call(this);
},
});
Key Points
- A zombie is a detached element whose view still holds live subscriptions
- Caused by on() instead of listenTo, by .empty() instead of remove(), and by re-render without child cleanup
- Symptom: a handler firing N times after N screen visits
- Detect with a Chrome heap snapshot filtered on Detached nodes
- Fix: a child registry plus removeChildren() called from render() and remove()
Q17Walk through exactly what model.set() does to change events, including {silent: true}, changedAttributes() and previous().
IntermediateModels
Answer
set() first normalises the arguments into an attribute hash, runs validation if {validate: true} was passed, then compares each incoming value against the current one using _.isEqual. Anything that differs is recorded in this.changed and written into this.attributes, with the old value stashed in _previousAttributes. Then, unless silenced, it fires one change:key event per changed attribute in the order they appear in the hash, and finally a single change event for the whole batch.
That ordering is the point of the design: a listener on change sees a fully consistent model, never a half-applied update, which is why a change handler is the right place to re-render and a change:key handler is the right place for a targeted DOM tweak. Because comparison uses _.isEqual, setting an array or object to a structurally identical value fires nothing, and conversely mutating a nested object in place and setting the same reference back also fires nothing, since old and new are the same object. That second case is the bug people hit with nested state, and the workaround is to set a fresh object or call model.trigger('change:key') by hand. changedAttributes() returns a hash of what changed in the last set, or false when nothing did, and you can pass it a diff to test hypothetically. previous('key') and previousAttributes() are only meaningful inside a change handler, because the next set overwrites them. {silent: true} suppresses the events but still records the change, so the very next non-silent set fires change for the silently-changed attribute too, which surprises people who expect silent to mean invisible.
const app = new Backbone.Model({ status: 'applied', score: 70, tags: ['a'] });
app.on('change:status', (m, value, opts) => console.log('1. status ->', value));
app.on('change:score', (m, value) => console.log('2. score ->', value));
app.on('change', (m) => {
console.log('3. batch', m.changedAttributes()); // { status: 'hired', score: 90 }
console.log(' was ', m.previous('status')); // 'applied'
});
app.set({ status: 'hired', score: 90 });
// logs 1, then 2, then 3 exactly once
// _.isEqual means a structurally identical value is a no-op
app.set('tags', ['a']); // no event
// In-place mutation is invisible to set()
app.get('tags').push('b');
app.set('tags', app.get('tags')); // same reference, still no event
app.set('tags', [...app.get('tags')]); // new reference, fires change:tags
// silent still records the change; it surfaces on the NEXT non-silent set
app.set({ score: 95 }, { silent: true }); // nothing fires
app.set({ status: 'joined' }); // fires change:status AND change:score
Q18How does model validation work in Backbone, and why does save() sometimes return false instead of a jqXHR?
IntermediateValidation
Answer
Define a validate(attrs, options) method. Return nothing when the data is valid, and return anything truthy, a string, an object or an array, when it is not. Backbone treats that return value as the error, stores it on model.validationError, and triggers an invalid event with the model and the error as arguments.
The counter-intuitive part is when validate runs. It does not run on every set. It runs on save() always, and on set() only when you pass {validate: true}.
That asymmetry is deliberate, it lets intermediate UI states exist without the model screaming, but it means a model can quite legitimately hold invalid attributes right up until you try to persist it. isValid() runs the validator on demand and returns a boolean, populating validationError as a side effect. The behaviour interviewers probe: when validation fails, save() and a validating set() both return false rather than a jqXHR or the model, so chaining .done() or .then() onto save() throws TypeError: Cannot read properties of false. Always test the return value, or listen for the invalid event, before treating the result as a promise.
A second detail is that validate should stay synchronous. Asynchronous checks such as server-side uniqueness cannot be expressed here, they belong in a separate method that resolves a promise before you call save. Most production codebases return a keyed object from validate so the view can highlight individual fields rather than showing one generic message.
const Candidate = Backbone.Model.extend({
urlRoot: '/api/candidates',
validate(attrs) {
const errors = {};
if (!attrs.email || !/^[^@\s]+@[^@\s]+$/.test(attrs.email)) errors.email = 'Invalid email';
if (attrs.phone && !/^[6-9]\d{9}$/.test(attrs.phone)) errors.phone = 'Enter a 10-digit Indian mobile';
if (attrs.expectedCtc != null && attrs.expectedCtc < 0) errors.expectedCtc = 'Cannot be negative';
return _.isEmpty(errors) ? undefined : errors; // truthy means invalid
},
});
const c = new Candidate({ email: 'nope', phone: '12345' });
c.on('invalid', (model, errors) => {
Object.entries(errors).forEach(([field, msg]) => showFieldError(field, msg));
});
c.set({ email: 'nope' }); // validate does NOT run
c.set({ email: 'nope' }, { validate: true }); // runs, returns false, fires invalid
const xhr = c.save();
if (xhr === false) {
console.log(c.validationError); // { email: ..., phone: ... }
} else {
xhr.done(onSaved).fail(onFailed); // only safe once you know it is not false
}
Key Points
- validate returns falsy for valid, anything truthy for invalid
- Runs on save() always, on set() only with {validate: true}
- Failure sets model.validationError and fires an invalid event
- save() returns false on validation failure, so guard before chaining
- Keep validate synchronous; async uniqueness checks belong elsewhere
Q19What is the difference between collection.fetch() and collection.fetch({reset: true}), and what are the add, remove and merge options?
IntermediateCollections
Answer
Since Backbone 1.0 the default fetch is a smart merge, not a replacement. Under the hood it calls collection.set(response) with {add: true, remove: true, merge: true}. Models in the response that are new to the collection are added and fire add.
Models already present, matched by id, have their attributes merged, which fires the usual change events on those existing model instances. Models in the collection that are absent from the response are removed and fire remove. One update event summarises the batch and carries a changes object with added, removed and merged arrays.
The critical property is identity preservation: the same JavaScript model objects survive the fetch, so views bound to them keep working and no re-render is needed for anything that only changed attributes. fetch({reset: true}) throws all of that away. It calls reset(), which discards every existing model, builds new instances, and fires a single reset event instead of any add, remove or change. Views bound to the old model instances are now bound to garbage.
The reason reset still exists is performance: a merge across ten thousand models does an id lookup and a diff per model, while reset just rebuilds the array. The practical rule is to use the default merge for anything a view is bound to, and reset only for a bulk reload where you re-render everything anyway. You can also tune the merge, for example fetch({remove: false}) is exactly how infinite scroll appends the next page without deleting the previous one.
const apps = new Applications();
apps.on('add', (m) => console.log('added', m.id));
apps.on('remove', (m) => console.log('removed', m.id));
apps.on('update', (col, opts) => {
const { added, removed, merged } = opts.changes;
console.log(added.length, removed.length, merged.length);
});
apps.on('reset', () => console.log('everything rebuilt'));
// Default: merge in place. Existing model instances survive, views stay bound.
apps.fetch();
// Nuclear: discards and rebuilds every model, fires only 'reset'
apps.fetch({ reset: true });
// Infinite scroll: append page 2 without deleting page 1
apps.fetch({
remove: false,
data: { page: 2, limit: 50 },
});
// Poll for new rows only, never touch what the user is editing
apps.fetch({ remove: false, merge: false });
Q20What does parse() do on a Model and on a Collection, and when is it invoked?
IntermediatePersistence
Answer
parse(response, options) is the translation layer between the server's JSON and Backbone's attribute shape. Backbone calls it in exactly two situations: after any successful sync, that is on fetch and on the response body of save, and inside the constructor only when you explicitly pass {parse: true}. It is not called when you set attributes directly.
On a Collection it must return the array of raw model hashes, which is how you unwrap envelope responses such as { data: [...], meta: { total: 812 } }, and it is the right place to stash pagination metadata on the collection instance before returning the inner array. On a Model it must return the attributes hash, which is where you rename server fields to client names, coerce ISO date strings into Date objects, flatten a nested object, or drop fields you never want in the model. Two subtleties come up in interviews.
First, the collection's parse runs before the models are constructed, and each resulting hash is then passed through the model's own parse only if the model was created with parse enabled, which Backbone does automatically for models built during a collection fetch. Second, save() also parses the response, so if your POST endpoint returns the created resource in an envelope, the model will store the envelope as its attributes unless parse handles that case too. Guard with a shape check rather than assuming the envelope is always present, because many APIs return a bare object on create and a wrapped one on read.
const Applications = Backbone.Collection.extend({
url: '/api/applications',
model: Application,
// Unwrap the envelope and keep the metadata
parse(response) {
this.total = response.meta ? response.meta.total : response.length;
this.nextCursor = response.meta && response.meta.next_cursor;
return response.data || response; // tolerate both shapes
},
});
const Application = Backbone.Model.extend({
idAttribute: '_id',
parse(response) {
const src = response && response.data ? response.data : response; // POST vs GET shapes
return {
_id: src._id,
candidateName: src.candidate_name, // snake_case -> camelCase
appliedAt: src.applied_at ? new Date(src.applied_at) : null,
city: src.location && src.location.city, // flatten nested
status: src.status,
};
},
});
// parse in the constructor only when you ask for it
const a = new Application(serverPayload, { parse: true });
Key Points
- Runs after every successful sync, and in the constructor only with {parse: true}
- Collection#parse returns the array, Model#parse returns the attributes hash
- The natural place to unwrap envelopes and stash pagination metadata
- save() parses the response too, so handle both create and read shapes
Q21How does Backbone.sync map CRUD to HTTP, and how do you override it to add auth headers or a fetch-based transport?
IntermediatePersistence
Answer
Backbone.sync(method, model, options) is the single choke point for all persistence. method is one of create, read, update, patch or delete, and Backbone's internal methodMap converts those to POST, GET, PUT, PATCH and DELETE respectively. sync builds a params object with the url from model.url(), the JSON body from model.toJSON() for the writing methods, contentType application/json, and dataType json, merges your options over it, then hands the whole thing to Backbone.ajax, which by default calls Backbone.$.ajax. It also fires a request event on the model and its collection before the call, and sync or error afterwards, and it returns the xhr, which Backbone stores as model.request. Because everything funnels through one function, there are three clean override points.
Set Backbone.ajax to redirect every request through your own transport, which is the smallest change if you only want to swap jQuery for fetch. Wrap Backbone.sync globally to inject an Authorization header, add a tenant id, or intercept a 401 and retry after refreshing the token. Or define sync on a single model class when just that resource speaks a different protocol.
Two legacy flags exist for servers that cannot handle real verbs: Backbone.emulateHTTP sends everything as POST with an X-HTTP-Method-Override header and a _method field, and Backbone.emulateJSON sends the body form-encoded under a model parameter. You will meet both in older enterprise codebases behind proxies that strip PUT and DELETE.
// 1. Global wrapper: auth header on every call, 401 refresh-and-retry
const originalSync = Backbone.sync;
Backbone.sync = function (method, model, options = {}) {
options.headers = { ...options.headers, Authorization: 'Bearer ' + tokenStore.access };
const error = options.error;
options.error = function (xhr, ...rest) {
if (xhr.status === 401 && !options._retried) {
return tokenStore.refresh().then(() => {
options._retried = true;
return Backbone.sync(method, model, options);
});
}
if (error) error.call(this, xhr, ...rest);
};
return originalSync.call(this, method, model, options);
};
// 2. Swap the transport only: drop jQuery.ajax for window.fetch
Backbone.ajax = function (opts) {
return fetch(opts.url, {
method: opts.type,
headers: { 'Content-Type': 'application/json', ...opts.headers },
body: opts.data,
credentials: 'include',
})
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((json) => { if (opts.success) opts.success(json); return json; },
(err) => { if (opts.error) opts.error(err); throw err; });
};
// 3. Legacy proxies that strip PUT and DELETE
Backbone.emulateHTTP = true;
Key Points
- methodMap: create POST, read GET, update PUT, patch PATCH, delete DELETE
- Backbone.ajax is the seam for swapping the HTTP client
- Wrapping Backbone.sync is the seam for headers, retries and tenancy
- request, sync and error events fire on the model and its collection
- emulateHTTP and emulateJSON exist for proxies that block real verbs
Q22What does {wait: true} change on save(), destroy() and collection.create()?
IntermediatePersistence
Answer
By default Backbone is optimistic. save() writes the attributes to the model and fires change immediately, then sends the request. destroy() removes the model from every collection and fires destroy immediately, then sends the DELETE. collection.create() adds the model to the collection and fires add immediately, then POSTs. The UI updates instantly, which feels fast, but if the request fails the client and server are now out of sync and nothing rolls back for you. {wait: true} inverts the order. save({status: 'hired'}, {wait: true}) validates first, sends the request with the proposed attributes, and only applies them to the model, firing change, once the server responds successfully. destroy({wait: true}) keeps the model in its collections until the DELETE returns 2xx. create(attrs, {wait: true}) does not add the model to the collection until the POST succeeds. The trade-off is exactly the one interviewers want you to articulate: optimistic gives a snappier interface but needs manual rollback on failure, pessimistic is always consistent but shows a spinner.
Pick per action based on the cost of being wrong. A checkbox toggle on a to-do list is fine optimistic. Anything that moves money, changes a hiring decision or deletes a record should use wait, because an optimistic delete that silently fails leaves the user believing a row is gone until they refresh. If you do go optimistic, capture previousAttributes in the error handler and restore them, and remember that on failure with wait the model was never mutated at all, so there is nothing to undo.
// Optimistic: UI updates now, you own the rollback
const before = _.clone(offer.attributes);
offer.save({ status: 'accepted' }, {
error(model, xhr) {
model.set(before); // manual rollback
toast.error('Could not update, status reverted');
},
});
// Pessimistic: nothing changes locally until the server agrees
offer.save({ status: 'accepted' }, {
wait: true,
success() { toast.ok('Offer accepted'); },
error(model, xhr) { toast.error(xhr.responseJSON?.message || 'Failed'); },
});
// Destroy: wait keeps the row on screen until the DELETE returns 2xx
candidate.destroy({
wait: true,
success() { toast.ok('Candidate removed'); },
error() { toast.error('Delete failed, row restored'); },
});
// Create: model is not added to the collection until the POST succeeds
applications.create({ candidateId: 12, jobId: 4 }, {
wait: true,
error() { toast.error('Could not create application'); },
});
Q23When would you use save(attrs, {patch: true}), and what exactly goes on the wire?
IntermediatePersistence
Answer
A normal save() sends the model's entire toJSON() output with PUT, which is REST-correct for a full replacement but wasteful and occasionally destructive. If two people have the same record open and one saves after changing a single field, their PUT carries every other field as it was when they loaded the page, silently reverting the other person's edits. That is last-write-wins across the whole document, and in a hiring or billing console it produces support tickets nobody can reproduce. save(attrs, {patch: true}) changes two things: Backbone.sync is called with the patch method, so the request goes out as HTTP PATCH, and the body contains only the attributes you passed into save, not the full model.
The server applies a partial update and the fields you did not send are untouched. Three details to get right. First, patch only sends the attributes in the call, so save(null, {patch: true}) sends an empty body, which is almost never what you want.
Second, the model is still updated optimistically unless you also pass wait: true, so patch and wait compose. Third, your API has to actually support PATCH, and plenty of older Java and .NET stacks behind corporate proxies do not, which is where Backbone.emulateHTTP comes in, converting the request to a POST with an X-HTTP-Method-Override header. A useful companion pattern is to build the patch body from model.changedAttributes() so you send exactly the diff after a form has been edited in place.
// Full PUT: sends every attribute, can clobber a concurrent editor
candidate.save({ status: 'shortlisted' });
// PUT /api/candidates/88
// { _id: 88, name: 'Neha', email: '...', phone: '...', status: 'shortlisted', ... }
// PATCH: sends only what you passed
candidate.save({ status: 'shortlisted' }, { patch: true, wait: true });
// PATCH /api/candidates/88
// { status: 'shortlisted' }
// Send exactly the diff after an in-place form edit
const FormView = Backbone.View.extend({
saveDirty() {
this.model.set(this.readForm()); // local, no request yet
const diff = this.model.changedAttributes();
if (!diff) return; // nothing changed
return this.model.save(diff, { patch: true, wait: true });
},
});
// Legacy proxy that strips PATCH: POST + X-HTTP-Method-Override
Backbone.emulateHTTP = true;
Q24How does the comparator work on a Backbone.Collection, and what is the difference between the one-argument and two-argument forms?
IntermediateCollections
Answer
comparator can be three things and Backbone picks its behaviour from the function's arity. A string is the shorthand for sorting ascending by that attribute, implemented as _.sortBy on model.get(attr). A function taking one argument is also a sortBy: it returns a value for each model and Backbone sorts on that value, which is the right form for derived keys such as a lowercased name or a negated number for descending order.
A function taking two arguments is a classic comparator: it receives two models and must return a negative number, zero or a positive number, and it is what you need for multi-key sorts such as status first, then applied date descending. Once a comparator exists, add() inserts each model at its sorted position rather than appending, using a binary search, so adding in bulk stays reasonable but is not free. sort() re-sorts and fires a sort event; note that changing an attribute the comparator depends on does not re-sort automatically, the collection has no idea which attributes the function reads. The standard fix is to listen for the relevant change event and call sort().
Two performance notes matter at scale. Passing {sort: false} to add lets you insert many models and sort once at the end. And a two-argument comparator is called O(n log n) times, so anything expensive inside it, date parsing, string localeCompare with options, or a get() chain, shows up immediately on a collection of a few thousand models. Precompute the sort key onto the model instead.
const Applications = Backbone.Collection.extend({
// 1) string: ascending by attribute
// comparator: 'appliedAt',
// 2) one argument: sortBy on a derived key (negate for descending)
// comparator: (m) => -m.get('score'),
// 3) two arguments: full comparator, needed for multi-key sorts
comparator(a, b) {
const rank = { interview: 0, shortlisted: 1, applied: 2, rejected: 3 };
const byStatus = rank[a.get('status')] - rank[b.get('status')];
if (byStatus !== 0) return byStatus;
return b.get('appliedTs') - a.get('appliedTs'); // newest first
},
});
const apps = new Applications();
// Changing a sort key does NOT re-sort automatically
apps.on('change:status', () => apps.sort());
apps.on('sort', () => listView.render());
// Bulk insert: skip per-add binary insertion, sort once at the end
apps.add(bigBatch, { sort: false });
apps.sort();
Key Points
- Arity decides behaviour: string and 1-arg are sortBy, 2-arg is a real comparator
- add() inserts in sorted position via binary search once a comparator exists
- Changing a sort-key attribute does not re-sort, call sort() from a change listener
- Use {sort: false} for bulk inserts, then a single sort()
- Keep the comparator cheap, it runs O(n log n) times
Q25What is the right way to render a collection of a few thousand models without freezing the browser?
IntermediatePerformance
Answer
The naive Backbone list view does two fatal things: it appends each row directly to this.$el inside the loop, forcing a layout and paint per row, and it re-renders the entire list on every add, remove or change. Four fixes cover almost every case. First, batch the DOM writes: build a DocumentFragment, append each row's el to it, and attach the fragment once, so the browser lays out a single time.
Second, render granularly: bind the parent to add, remove and reset, and let each row view bind to its own model's change event, so editing one row touches one node instead of rebuilding a thousand. Third, avoid re-render storms by debouncing the parent's render, since a fetch that merges two hundred models fires two hundred add events in a tight loop, and one _.debounce or a single update listener collapses that into one pass. Fourth, when the list is genuinely large, stop rendering rows the user cannot see: either paginate on the server, or implement windowing where you render only the visible slice plus a buffer and recompute on scroll.
There is a fifth trick specific to Backbone: for a read-only table, skip per-row views entirely and build one HTML string from collection.toJSON() through a single template call, then set it with one $el.html(). You lose per-row event isolation, but a single delegated handler on the parent with a data-cid lookup recovers the interactivity at a fraction of the object count. In practice a Backbone grid that renders five thousand rows should be doing server-side pagination anyway, and interviewers usually want to hear you say that first.
const GridView = Backbone.View.extend({
initialize() {
this.rows = new Map(); // cid -> row view
// 'update' fires ONCE per batch; 'add' would fire N times
this.listenTo(this.collection, 'update reset sort', this.renderAll);
this.renderAll = _.debounce(this.renderAll.bind(this), 16);
},
renderAll() {
this.rows.forEach((v) => v.remove());
this.rows.clear();
const frag = document.createDocumentFragment();
this.collection.each((model) => {
const row = new RowView({ model }); // row binds to its OWN change event
this.rows.set(model.cid, row);
frag.appendChild(row.render().el);
});
this.el.replaceChildren(frag); // one layout, one paint
return this;
},
remove() {
this.rows.forEach((v) => v.remove());
this.rows.clear();
return Backbone.View.prototype.remove.call(this);
},
});
Q26Two fetches on the same collection are in flight and the slower one returns last. How do you stop stale data overwriting fresh data?
IntermediateProduction Failure Modes
Answer
This is the single most common data bug in Backbone search screens, and it happens because Backbone.sync has no concept of a current request. A user types react, you fetch, they type react developer, you fetch again, and if the first response is slower the list ends up showing results for the earlier query. Backbone will not protect you, because the success callback of the first request runs whenever it arrives and happily calls set() on the collection.
There are three workable fixes and a good answer names the trade-offs. Abort the previous request: Backbone.sync returns the xhr and stores it on the object as model.request, so you can keep a reference and call abort() before issuing a new one. This is cheapest but abort fires the error callback, so you must ignore xhr.statusText === 'abort' or you will show a spurious error toast, and an aborted request may still have hit the server.
Use a request token: increment a counter before each fetch, capture it in the closure, and drop the response if the counter has moved on. This is the most robust because it works with any transport, including a fetch-based Backbone.ajax where abort needs an AbortController. Or serialise: disable the input until the in-flight request settles, acceptable for a save button, unacceptable for search-as-you-type.
In every version, debounce the trigger at around 250 to 300 milliseconds first, which removes most of the concurrency before you have to reason about it. Backbone's request and sync events give you clean hooks for the spinner on either side.
const SearchView = Backbone.View.extend({
initialize() {
this.seq = 0;
this.listenTo(this.collection, 'request', () => this.$('.spinner').show());
this.listenTo(this.collection, 'sync error', () => this.$('.spinner').hide());
},
events: { 'input .js-q': 'onType' },
onType: _.debounce(function (e) {
this.search(e.currentTarget.value.trim());
}, 250),
search(q) {
// 1) abort the previous request if the transport supports it
if (this.xhr && this.xhr.abort) this.xhr.abort();
// 2) token guard: works even when abort does not
const token = ++this.seq;
this.xhr = this.collection.fetch({
data: { q },
reset: false,
success: (collection, resp, options) => {
if (token !== this.seq) return; // a newer query already landed
this.render();
},
error: (collection, xhr) => {
if (xhr.statusText === 'abort' || token !== this.seq) return;
this.showError(xhr);
},
});
},
});
Key Points
- Backbone.sync returns the xhr, and stores it on the object as .request
- abort() is cheapest but fires error with statusText 'abort', filter that out
- A monotonically increasing token is transport-agnostic and always correct
- Debounce the input first, it removes most of the concurrency
- Use the request and sync events to drive the loading indicator
Q27How do you handle sync errors globally in a Backbone app rather than passing an error callback everywhere?
IntermediateError Handling
Answer
Backbone gives you four layers and a good answer picks the right one per concern. The per-call options.error callback is the most local and belongs to anything with a specific recovery, such as restoring attributes after a failed optimistic save. The error event is fired on the model and bubbles to its collection, which lets a list view show an inline banner without every call site cooperating.
For anything genuinely cross-cutting, wrap Backbone.sync itself: a single wrapper can attach an Authorization header, retry once on 401 after refreshing the token, log 5xx responses to your error tracker with the model url and method attached, and translate the server's error envelope into a consistent shape before your handlers see it. That wrapper is also the only place where you can reliably count failures per endpoint, which is what you want feeding a dashboard. Finally, if you are still on jQuery, $(document).ajaxError gives a global net for anything that slipped through, including non-Backbone calls made by legacy code in the same page.
Two practical points interviewers listen for. Backbone gives an unhandled sync failure no default behaviour at all, so without one of these layers a failed save is completely invisible to the user, which is exactly how a legacy admin panel ends up losing edits. And error handlers should distinguish 4xx from 5xx and from network failure, since retrying a 422 is pointless while retrying a timeout is often correct.
// Layer 1: per model, bubbles to the collection
applications.on('error', (model, xhr) => {
if (xhr.status === 422) inlineErrors(model, xhr.responseJSON);
});
// Layer 2: one wrapper for auth, retries, logging and error normalisation
const baseSync = Backbone.sync;
Backbone.sync = function (method, model, options = {}) {
const started = Date.now();
const userError = options.error;
options.error = function (xhr, textStatus) {
const ctx = { method, url: _.result(model, 'url'), status: xhr.status, ms: Date.now() - started };
if (xhr.status === 0 || textStatus === 'timeout') {
appBus.trigger('net:offline', ctx);
} else if (xhr.status === 401) {
appBus.trigger('session:expired', ctx);
} else if (xhr.status >= 500) {
errorTracker.captureMessage('backbone sync 5xx', { extra: ctx });
}
if (userError) userError.apply(this, arguments);
};
return baseSync.call(this, method, model, options);
};
// Layer 3: catch-all for legacy jQuery calls outside Backbone
$(document).ajaxError((e, xhr, settings) => console.warn('ajax fail', settings.url, xhr.status));
Q28How do you unit test Backbone models, collections and views without hitting a real server?
IntermediateTesting
Answer
Backbone is unusually testable because everything funnels through Backbone.sync and every view owns exactly one element. Models and collections are pure logic and need no DOM at all: assert on defaults, validate(), parse() with a realistic server payload, comparator ordering, and derived methods. For anything that touches the network, the classic tool is Sinon's fake server, which patches XMLHttpRequest so jQuery.ajax and therefore Backbone.sync resolve against canned responses with no timers or real sockets. sinon.createFakeServer(), then server.respondWith(method, urlOrRegExp, [status, headers, body]), then server.respond() to flush, and you can assert both on the resulting collection state and on server.requests[0].requestBody to prove the payload was correct, which is how you test that patch really sent only one field.
If your app has already replaced Backbone.ajax with fetch, stub Backbone.ajax directly or use a fetch mock, since the XHR-level fake server no longer intercepts anything. For views, the modern setup is Jest or Vitest with the jsdom environment, jQuery bound to Backbone.$, and a container element created in beforeEach and torn down in afterEach. Render, then assert on view.$el.find(...) text and classes, and drive interactions by triggering real jQuery events so the delegated events hash is exercised rather than calling the handler method directly. The test worth writing that most teams skip: construct a view, render, remove, then trigger the model event and assert the handler did not run, which is a regression test against the zombie view problem.
import sinon from 'sinon';
describe('Applications', () => {
let server;
beforeEach(() => { server = sinon.createFakeServer(); });
afterEach(() => server.restore());
it('unwraps the envelope and keeps metadata', () => {
const apps = new Applications();
server.respondWith('GET', '/api/applications', [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({ data: [{ _id: 1, candidate_name: 'Neha' }], meta: { total: 812 } }),
]);
apps.fetch();
server.respond();
expect(apps.length).toBe(1);
expect(apps.total).toBe(812);
expect(apps.at(0).get('candidateName')).toBe('Neha');
});
it('patch sends only the changed field', () => {
const m = new Application({ _id: 7, status: 'applied', notes: 'keep me' });
m.save({ status: 'hired' }, { patch: true });
expect(JSON.parse(server.requests[0].requestBody)).toEqual({ status: 'hired' });
});
it('a removed view stops reacting to its model', () => {
const m = new Application({ _id: 7 });
const v = new RowView({ model: m }).render();
const spy = jest.spyOn(v, 'render');
v.remove();
m.set('status', 'hired');
expect(spy).not.toHaveBeenCalled();
});
});
Key Points
- Models, collections, parse and comparator need no DOM to test
- sinon.createFakeServer patches XHR, so Backbone.sync resolves offline
- Assert on server.requests[0].requestBody to prove the wire payload
- A fetch-based Backbone.ajax bypasses the XHR fake server, stub it instead
- Drive views through real jQuery events so the events hash is covered
Q29A Backbone screen holding around ten thousand models has become sluggish. How do you profile and fix it?
AdvancedPerformance
Answer
Measure before touching anything. A Chrome performance profile over the slow interaction tells you within a minute whether the cost is scripting, layout or paint, and the shape of the flame chart usually identifies the culprit outright. Four patterns dominate in Backbone at this size.
Layout thrashing from per-row DOM appends, visible as a long strip of alternating Layout and Recalculate Style, fixed with a DocumentFragment and a single attach. Event storms, visible as thousands of identical short tasks, caused by a parent bound to add rather than update, or by several change:key listeners each triggering a render, fixed by listening to update and debouncing render. Comparator cost, visible as one long sort task, because a two-argument comparator that parses dates or calls localeCompare runs O(n log n) times, fixed by precomputing a numeric sort key onto each model at parse time.
And model construction itself: ten thousand models means ten thousand objects each with a cid, an attributes hash, an events registry and an entry in the collection index, which is several megabytes of retained heap before you have rendered anything. If the profile says construction and memory, the honest fix is architectural rather than micro: paginate server side, or keep raw JSON in a plain array and build models lazily only for rows the user actually opens. On the collection itself, use {sort: false} for bulk adds followed by one sort, prefer collection.get over where for lookups since get is indexed and where is a linear scan, and never rebuild a collection with reset when the default merging set would preserve the existing model instances and their bound views.
// 1) Precompute sort keys at parse time so the comparator stays trivial
const Application = Backbone.Model.extend({
parse(src) {
return {
...src,
appliedTs: Date.parse(src.applied_at), // number, not a Date or string
nameKey: (src.candidate_name || '').toLowerCase(),
};
},
});
const Applications = Backbone.Collection.extend({
model: Application,
comparator: (m) => -m.get('appliedTs'), // 1-arg sortBy, no parsing per call
});
// 2) Bulk load: one sort, one render, instead of N of each
const apps = new Applications();
apps.add(tenThousandRows, { sort: false, parse: true });
apps.sort();
// 3) Indexed lookup beats a linear scan
apps.get(8811); // O(1) through _byId
apps.where({ _id: 8811 }); // O(n), avoid in hot paths
// 4) Cheap instrumentation to find the real hotspot
console.time('render');
grid.renderAll();
console.timeEnd('render');
performance.measure('grid-render');
Key Points
- Profile first: scripting, layout and paint have completely different fixes
- DocumentFragment plus one attach removes layout thrashing
- Bind the parent to update, not add, and debounce render
- Precompute numeric sort keys, comparators run O(n log n) times
- get() is indexed, where() is a linear scan
- Ten thousand models is a pagination problem, not a micro-optimisation problem
Q30How do you bundle Backbone with Vite or webpack in 2026, and what interop problems should you expect?
AdvancedTooling
Answer
Backbone ships as UMD with a CommonJS-style entry, and Underscore and jQuery are declared as dependencies, so a modern bundler resolves all three without configuration. The friction is entirely about globals and interop. First, legacy code inside a Backbone app almost always assumes window.$, window._ and window.Backbone exist, because that was the script-tag world it was written in.
Bundlers create no globals, so the migration either sets them explicitly on a bootstrap module or uses webpack's ProvidePlugin, or in Vite an explicit assignment in the entry file, which is cleaner and easier to delete later. Second, Backbone.$ must be assigned before the first View is constructed, otherwise this.$el is undefined and the stack trace points somewhere useless. Third, the default export interop: with esModuleInterop or Vite's ESM handling, import Backbone from 'backbone' works, but a codebase mixing require and import can end up with two Backbone instances, which breaks the Backbone.history singleton in a way that is genuinely hard to diagnose, and the tell is the 'already been started' error appearing when you only called start() once.
Fourth, tree shaking does nothing here, Backbone is one monolithic object graph, so the whole library ships regardless. Fifth, if you precompile Underscore templates at build time to satisfy a strict Content-Security-Policy, that runs as a bundler plugin or a prebuild step. Practically, most 2026 work on these codebases is exactly this: getting an old script-tag or RequireJS app into Vite so the team can start importing React components into it.
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
// guarantee ONE copy of each: two Backbones break the history singleton
dedupe: ['backbone', 'underscore', 'jquery'],
},
optimizeDeps: { include: ['backbone', 'underscore', 'jquery'] },
});
// src/legacy-globals.js (imported first in main.js, deleted last in the migration)
import $ from 'jquery';
import _ from 'underscore';
import Backbone from 'backbone';
Backbone.$ = $; // MUST happen before any View is constructed
window.$ = window.jQuery = $;
window._ = _;
window.Backbone = Backbone; // legacy modules still read the global
export { $, _, Backbone };
// webpack equivalent
// new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery', _: 'underscore' })
Q31How would you migrate a large Backbone app to React incrementally, without a rewrite?
AdvancedMigration
Answer
The strangler pattern, applied at the view boundary. Backbone views own a single DOM element and expose a clean lifecycle, which makes them an unusually good host for a React root. The mechanics: in the Backbone view's render, create the React root against this.el once and render your component into it, then in an overridden remove(), unmount the root before calling the parent remove, otherwise you leak a React tree on every navigation.
State flows in by passing model attributes as props and re-rendering on change, and flows out by passing callbacks that call model.set or model.save. For a cleaner bridge, wrap the subscription in a hook built on useSyncExternalStore, subscribing to the model's change event and reading toJSON as the snapshot, which gives React-idiomatic reads of Backbone state without a Redux-style rewrite of the data layer. Sequence matters as much as mechanics.
Start with leaf views that have no children and little routing involvement, typically modals, forms and detail panels. Keep Backbone owning the router and the data layer for as long as possible, because models and collections are the least painful part of Backbone and the router is the most entangled. Move routing last, either by handing whole routes to a React router mounted under one Backbone route, or by replacing Backbone.history in a single cutover once most screens are React.
Enforce a rule that new code never adds a Backbone view, and put a lint rule behind it. Expect the shared-mutable-state mismatch to be the real difficulty: React assumes immutable snapshots, Backbone models are mutated in place, so any component that reads model.attributes directly rather than through the snapshot will fail to re-render.
import { createRoot } from 'react-dom/client';
import { useSyncExternalStore, useCallback } from 'react';
// Read a Backbone model from React, idiomatically
export function useBackboneModel(model) {
const subscribe = useCallback((cb) => {
model.on('change sync', cb);
return () => model.off('change sync', cb);
}, [model]);
// cache the snapshot: useSyncExternalStore needs referential stability
const getSnapshot = useCallback(() => model.attributes, [model]);
useSyncExternalStore(subscribe, getSnapshot);
return model;
}
// Backbone view that hosts a React tree
const CandidatePanel = Backbone.View.extend({
render() {
this.root = this.root || createRoot(this.el);
this.root.render(<CandidateCard model={this.model} />);
return this;
},
remove() {
if (this.root) {
this.root.unmount(); // without this, every navigation leaks a React tree
this.root = null;
}
return Backbone.View.prototype.remove.call(this);
},
});
function CandidateCard({ model }) {
useBackboneModel(model);
return <button onClick={() => model.save({ status: 'hired' }, { patch: true })}>
{model.get('candidateName')}
</button>;
}
Key Points
- Mount a React root on view.el in render, unmount it in an overridden remove
- useSyncExternalStore over the model change event gives clean React reads
- Migrate leaf views first, keep models and the router on Backbone longest
- Ban new Backbone views with a lint rule or the migration never finishes
- The hard part is mutable Backbone state versus React's snapshot assumption
Q32What does Marionette add on top of Backbone, and what is Backbone.Radio for?
AdvancedEcosystem
Answer
Marionette exists because every serious Backbone team ends up writing the same missing framework, and it decided to write it once. The pieces it supplies map directly onto Backbone's gaps. Regions manage a swappable area of the page and, critically, destroy the previous view before showing the next one, which eliminates the leading cause of zombie views.
CollectionView renders one child view per model and keeps the DOM in sync with add, remove and sort events without you rebuilding the list, and the child views are tracked so they are destroyed together. View, previously ItemView and LayoutView, adds a templating convention, a ui hash that caches jQuery lookups by name so you write this.ui.saveBtn instead of repeating selectors, and a real destroy lifecycle with onBeforeDestroy and onDestroy hooks. Application gives a single bootstrap entry point.
Behaviors let you factor shared view logic, tooltips or unsaved-changes guards, into reusable mixins. Backbone.Radio is the messaging layer, extracted so it can be used without Marionette. It provides named channels, each exposing an event bus plus a request and reply channel, so a view can ask for data without holding a reference to whoever owns it.
That decoupling is genuinely useful in large apps and genuinely dangerous when overused, because a Radio-heavy codebase is one where you cannot find who handles a message without grepping strings. The interview-relevant judgement is that Marionette solves memory and boilerplate, not rendering performance, and that adopting it in 2026 only makes sense for a codebase staying on Backbone for years rather than one on a React migration path.
Key Points
- Regions destroy the outgoing view automatically, killing most zombie leaks
- CollectionView keeps child views in sync with the collection and destroys them together
- The ui hash caches jQuery lookups; Behaviors factor out shared view logic
- Backbone.Radio adds named channels with request and reply, decoupling at the cost of traceability
- Adopt it only if the app is staying on Backbone, not if it is migrating out
Q33Backbone has no support for nested models or relations. How do you model a parent with children in a real app?
AdvancedArchitecture
Answer
Backbone's attributes hash is flat by design, and this is the gap that bites hardest on real APIs. Set a nested object as an attribute and you get three problems: change fires only when the reference changes, not when a property inside it does, so mutating job.get('company').name is invisible; toJSON is shallow so that nested object is shared with anything you hand the clone to; and you cannot bind a view to a nested field. Three approaches exist.
The pragmatic one, and the one most production codebases use, is to instantiate child models and collections yourself in parse or initialize, store them as properties rather than attributes, proxy their events up to the parent, and override toJSON to reassemble the nested payload for the server. It is fifteen lines per relation, fully explicit, and it never surprises anyone. The second is a plugin, historically Backbone.Relational or Backbone-associations, which give declarative relations with automatic reverse links and an identity map.
They work, but they add nontrivial behaviour to model construction, their maintenance has been quiet for years, and debugging a relation cycle in someone else's plugin is unpleasant, so I would not add one to a codebase today. The third is to flatten in parse: pull company.name up as companyName and forget the nesting exists, which is perfect when the child is read-only display data and wrong the moment the child needs its own persistence. The interview signal is knowing that Backbone deliberately omits this, and choosing based on whether the child is edited independently.
const Job = Backbone.Model.extend({
urlRoot: '/api/jobs',
parse(resp) {
// build children as PROPERTIES, not attributes
if (resp.company) {
this.company = this.company || new Company();
this.company.set(this.company.parse(resp.company));
}
if (resp.rounds) {
this.rounds = this.rounds || new Rounds();
this.rounds.set(resp.rounds, { parse: true });
}
return _.omit(resp, 'company', 'rounds'); // keep attributes flat
},
initialize() {
this.company = this.company || new Company();
this.rounds = this.rounds || new Rounds();
// proxy child events so a parent view can bind to one object
this.listenTo(this.company, 'change', (m) => this.trigger('change:company', this, m));
this.listenTo(this.rounds, 'add remove change', () => this.trigger('change:rounds', this));
},
// reassemble the nested payload the server expects
toJSON() {
return {
...Backbone.Model.prototype.toJSON.call(this),
company: this.company.toJSON(),
rounds: this.rounds.toJSON(),
};
},
});
Q34What breaks when you switch a Backbone app from hash routing to pushState in production, and how do you add route guards?
AdvancedRouting
Answer
Four things break, and all of them look like something else. The first is a hard refresh on a deep route returning 404, because the browser now requests /app/jobs/12 from the server as a real path. The server has to rewrite every non-asset path to index.html, which is a try_files directive in nginx, a CloudFront error-response mapping for an S3-hosted bundle, or a catch-all route in Express.
The second is the root option: if the app is served from a subdirectory, Backbone.history.start({pushState: true, root: '/app/'}) is mandatory, and without it the fragment includes the prefix and no route ever matches, which presents as a blank screen with no error. The third is that every anchor in the app now performs a full page load unless you intercept clicks and route them through router.navigate, which is the one piece of plumbing hash routing gave you for free. The standard fix is one delegated handler on the document that catches same-origin links without a target attribute and calls preventDefault.
The fourth is analytics and deep-link integrations that were reading location.hash. On guards, Backbone.Router#execute(callback, args, name) is the documented hook: it wraps every route invocation, so returning false from an overridden execute cancels the route entirely, which is exactly what an authentication check needs. Redirect with navigate({trigger: true, replace: true}) so the blocked URL does not sit in the history stack and trap the back button. Keep the guard cheap and synchronous; if it needs a network call, resolve the session once at bootstrap before calling history.start.
const AppRouter = Backbone.Router.extend({
routes: { '': 'home', 'login': 'login', 'jobs/:id': 'job', 'admin/*rest': 'admin' },
// runs before EVERY route; return false to cancel it
execute(callback, args, name) {
const publicRoutes = ['home', 'login'];
if (!publicRoutes.includes(name) && !session.isLoggedIn()) {
session.returnTo = Backbone.history.getFragment();
this.navigate('login', { trigger: true, replace: true });
return false;
}
if (name === 'admin' && !session.hasRole('admin')) {
this.navigate('', { trigger: true, replace: true });
return false;
}
if (callback) callback.apply(this, args);
},
});
Backbone.history.start({ pushState: true, root: '/app/' });
// pushState means plain anchors reload the page unless you intercept them
$(document).on('click', 'a[href^="/"]:not([target])', function (e) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.which !== 1) return;
e.preventDefault();
const path = this.getAttribute('href').replace(/^\/app\//, '');
Backbone.history.navigate(path, { trigger: true });
});
// nginx: try_files $uri $uri/ /app/index.html;
Key Points
- The server must rewrite every non-asset path to index.html or refresh 404s
- root is mandatory when the app is not at the domain root, and its absence is silent
- Intercept same-origin anchor clicks or every link becomes a full page load
- Override execute() to guard routes, return false to cancel
- Redirect with replace: true so the blocked URL does not trap the back button
Q35How would you write a custom Backbone.sync adapter, for example an offline-first localStorage or IndexedDB layer?
AdvancedPersistence
Answer
Because Backbone.sync is one function with a fixed contract, replacing it is the cleanest extension point the library has. The contract: sync(method, model, options) receives one of create, read, update, patch or delete, must eventually call options.success with the data the model should absorb, or options.error on failure, and should return something thenable so callers that chain on save() keep working. Everything else, the model's request, sync and error events and the resulting set(), is handled by Backbone around your function.
A localStorage adapter therefore reduces to a switch on method that reads or writes a JSON blob keyed by a store name and the model id, generating an id for create since there is no server to allocate one. Assign it either per class, by defining sync on the model or collection, or globally by reassigning Backbone.sync. The interesting version is offline-first rather than offline-only: attempt the network first, fall back to the local store on failure, and queue the mutation for replay when connectivity returns.
Three things to handle honestly in that design. Conflict resolution needs a rule, usually a server-wins policy with an updatedAt comparison, because last-write-wins across a replay queue silently loses data. Ids created offline must be reconciled when the server assigns real ones, which is where the cid becomes the stable local key.
And the queue itself has to survive a reload, so it belongs in IndexedDB rather than in memory. Mention navigator.onLine as a hint rather than a source of truth, since it reports the interface state, not reachability.
function localSync(storeName) {
const key = (id) => storeName + ':' + id;
const read = (id) => JSON.parse(localStorage.getItem(key(id)));
const index = () => JSON.parse(localStorage.getItem(storeName + ':index') || '[]');
const saveIndex = (ids) => localStorage.setItem(storeName + ':index', JSON.stringify(ids));
return function sync(method, model, options = {}) {
let resp;
try {
if (method === 'read') {
resp = model.id ? read(model.id) : index().map((id) => read(id));
} else if (method === 'create') {
const id = model.id || 'local-' + model.cid;
resp = { ...model.toJSON(), [model.idAttribute]: id };
localStorage.setItem(key(id), JSON.stringify(resp));
saveIndex([...index(), id]);
} else if (method === 'update' || method === 'patch') {
resp = { ...read(model.id), ...model.toJSON() };
localStorage.setItem(key(model.id), JSON.stringify(resp));
} else if (method === 'delete') {
localStorage.removeItem(key(model.id));
saveIndex(index().filter((id) => id !== model.id));
resp = {};
}
} catch (err) {
if (options.error) options.error(err);
return Promise.reject(err);
}
if (options.success) options.success(resp);
model.trigger('sync', model, resp, options);
return Promise.resolve(resp);
};
}
const Drafts = Backbone.Collection.extend({ sync: localSync('drafts') });
Key Points
- sync(method, model, options) must call options.success or options.error and return a thenable
- Assign per class as a sync property, or globally by reassigning Backbone.sync
- Offline-first means network first, local fallback, and a durable replay queue
- Use the cid as the stable local key until the server assigns a real id
- navigator.onLine reports the interface, not reachability, so treat it as a hint
Frequently Asked Questions
What does a Backbone.js developer earn in India in 2026?
Roughly ₹5-15 LPA, and the spread is wider than the number suggests because Backbone is almost never the whole job. Two to four years of frontend experience with Backbone maintenance work at a services firm or a mid-sized product company typically lands ₹5-9 LPA. Product companies and global capability centres in Bengaluru, Pune, Gurugram and Hyderabad that are actively modernising a large Backbone application pay ₹12-20 LPA, but they are hiring for the migration skill as much as the framework: strong JavaScript fundamentals, React, and the judgement to carve a monolith into pieces without breaking revenue. Pure Backbone-only profiles have the weakest negotiating position, so pair it with React or TypeScript on your resume.
Is it worth learning Backbone.js in 2026?
Not as a first framework, and not for greenfield work. It is worth learning in two situations. One, you have been offered or assigned a role on an existing Backbone codebase, in which case a focused week gets you productive because the library is small enough to read end to end. Two, you want to genuinely understand what frameworks do for you: Backbone makes the cost of manual event unbinding, manual DOM updates and manual subview management painfully visible, and engineers who have felt that cost write better React. The market demand is real but narrow and shrinking, concentrated in maintenance and migration rather than new builds.
How long should I prepare for a Backbone interview, and what should I prioritise?
If you already know JavaScript and jQuery well, five to seven focused days is enough, because the entire library is about two thousand readable lines. Spend day one on models and collections, day two on views, templates and the events hash, day three on Backbone.Events, listenTo and the zombie view problem, day four on sync, fetch merge semantics, parse and the wait and patch options, and day five on the router and pushState. Then build one small app with a real API. The highest-yield preparation is reading the annotated Backbone source itself, since most interview answers at this level are literally descriptions of what the implementation does.
What is asked differently of a fresher versus an experienced candidate?
Freshers are asked what the pieces are and how they connect: define a model with defaults, render a collection into a list, wire an events hash, set up a router. Correct working code is the bar. Experienced candidates get almost no definitional questions. They are asked why an event handler fires four times, why a save silently did nothing, why a list flickers on every poll, why refreshing a pushState URL 404s, and how they would move the app off Backbone without a rewrite. Bring one concrete production story, a leak you found in a heap snapshot or a race condition you fixed, because at this level the debugging narrative is the differentiator.
How do I position Backbone experience when every job posting asks for React?
Frame it as data-layer and migration experience rather than as a framework you like. Backbone models and collections are a REST-backed state layer with events, so you have already dealt with normalisation, optimistic updates, cache invalidation and stale-response races, which are exactly the problems React Query and Redux Toolkit Query solve declaratively. Say that explicitly in interviews. Then show the migration angle: mounting React roots inside legacy views, bridging model events with useSyncExternalStore, and sequencing a strangler migration. Companies with a large Backbone codebase pay a premium for someone who can do that, and it is a far stronger story than claiming Backbone is still competitive.
Which adjacent skills should I pair with Backbone, and how does it compare to Ember or Knockout?
Pair it with modern JavaScript and TypeScript, jQuery (unavoidable in these codebases), Underscore or Lodash, Vite or webpack for the build migration, and React for the exit path. Marionette is worth a day if the target codebase uses it. Against Ember, Backbone is the opposite philosophy: Ember is convention-heavy with a router, data layer and CLI included, Backbone gives you the minimum and expects you to decide. Knockout occupies a different slot again, it is a two-way binding library with no models, collections or routing. All three appear mostly in maintenance contexts now, and interviewers for those roles care far more about how you reason about legacy code than about framework advocacy.
Introduction
Backbone.js is a roughly two-thousand-line library, not a framework. It gives you Backbone.Model, Backbone.Collection, Backbone.View, Backbone.Router, Backbone.history, the Backbone.Events mixin and Backbone.sync, then deliberately stops. There is no virtual DOM, no two-way binding, no component tree, no dependency injection and no built-in subview management. Everything past that line is your architecture, which is exactly why Backbone codebases vary so wildly in quality and why interviews for these roles focus far more on event lifecycle discipline and memory hygiene than on API trivia. The library still depends hard on Underscore.js and optionally on jQuery through the Backbone.$ hook.
Nobody in 2026 starts a greenfield product on Backbone. What does exist, in very large quantities, is production Backbone: admin consoles, billing dashboards, support desks, travel and events platforms, and internal tools that have been shipping revenue for a decade. Indian engineers meet this code inside global capability centres and in modernisation projects at services firms, where the actual job is to keep a Backbone app stable while carving out React or Vue islands around it. Interviewers therefore probe production failure modes: zombie views, duplicated delegated handlers, stale fetch responses overwriting fresh data, pushState routes that 404 on refresh, and Underscore templates breaking under a strict Content-Security-Policy.
This guide works through 35 Backbone.js interview questions, ordered from fundamentals to senior-level architecture. The basic section covers models, collections, views, templates and routing as the library actually implements them. The intermediate section is where most offers are decided: listenTo versus on, the change event pipeline, collection.fetch merge semantics, parse, sync overrides, wait and patch options, and race conditions. The advanced section covers profiling large collections, bundling a UMD library in Vite, running React inside a Backbone view without a rewrite, custom sync transports and pushState in production. Most answers include runnable code.
Ready to practice Backbone.js interviews?
Don't just read, practice these Backbone.js questions live with an AI interviewer that asks follow-ups and scores your answers.