Streaming media
Building search for a smart TV streaming platform
- LightningJS
- JavaScript
- WebGL
Search on a streaming platform looks simple from the couch. You type a few letters, results appear, you press OK. The interaction most people compare it to is Netflix or YouTube, and the comparison is fair — that is the bar.
What sits behind it is a fan-out. A single keyword goes to several content APIs at once, each returning a different kind of thing, and the interface has to resolve those responses into one ordered list that makes sense to someone holding a remote. That resolution is where the work is.
I built the search experience for a streaming platform on smart TVs using LightningJS.
One query, many sources
A streaming catalog is not one index. Movies, series, live channels, networks, and people each live behind their own endpoint, with their own response shape, their own latency profile, and their own idea of what a relevance score means.
So a keyword becomes N concurrent requests. That immediately raises questions the DOM and a single search endpoint would have answered for you.
When do you render? Waiting for all responses means the slowest endpoint sets your perceived latency. Rendering as each arrives means the list reorders under the user while they are looking at it — and on a TV, where the user tracks a focus highlight rather than a cursor, a list that reorders mid-look is disorienting in a way it isn't on a phone.
How do you order across sources? Each endpoint returns its own ranking, and those rankings are not comparable. An exact title match from the movies endpoint and a fuzzy match from the people endpoint both come back as "the top result." Merging them requires a ranking policy that lives in the client, because no single backend has the whole picture.
What happens when one fails? Partial failure is the normal case, not the exception. If the live-channels endpoint times out, the user should still get movies — but they should not silently get a result set that is missing a category they were looking for.
Why ordering is worse on a TV
On the web, a reordering result list is mildly annoying. On a TV it breaks the interaction.
The user is not pointing at anything. They are moving a highlight with a D-pad, and their model of the screen is positional — the thing they want is two to the right. If a slow endpoint returns and inserts three results ahead of the highlighted item, the highlight is now on something else, and the user has to re-read the row to work out where they are. Do that twice and they stop trusting the screen.
Every mitigation costs something. Lock ordering after first paint and you lose relevance when a better result arrives late. Append late results to the end regardless of score and you get stability at the price of correctness. Hold the highlight on its item as the list reflows and you preserve the user's position but shift the layout under a stationary cursor.
The input constraint shapes everything upstream
The user is typing with a directional pad on an on-screen keyboard. Each character is a cursor traversal plus an OK press. Nobody types a full title.
So the fan-out fires on two- and three-character prefixes, which means every endpoint is being asked its least selective question, which means result sets are large and the ranking problem is at its hardest exactly when you have the least information to solve it. Longer queries would be easier in every respect and you will almost never get one.
Request volume follows from this. A naive implementation fires N requests per keystroke, and a user pecking out six characters generates 6N, most superseded before they return. Debouncing is not optional, and the usual approach does not transfer cleanly: D-pad input is slow and irregular, with long pauses while the cursor travels between distant keys and then bursts on adjacent ones. A fixed timeout tuned for typing behaves badly in both directions — too eager during the pauses, too sluggish during the bursts.
Out-of-order responses are a related problem worth calling out separately. Slow irregular input plus multiple endpoints plus living-room network conditions means a response for "do" can land after a response for "doc." Each of the N streams needs its own staleness check, not just a global one.
LightningJS and what it takes away
LightningJS renders to a WebGL canvas rather than the DOM. On TV hardware that is the difference between a smooth app and a stuttering one — DOM-based TV apps degrade badly on the older devices that make up much of the installed base, and stutter is unmissable on a ten-foot screen.
The cost is that the browser stops helping. No reflow, no CSS layout, no native text input, no focus management. Every element is a texture at a coordinate you set.
Focus becomes an explicit system: a tree you maintain, key handlers you write for every direction, and no default behavior when you miss a case. Search is harder than most screens in this respect because it has several focusable regions the user moves between constantly — on-screen keyboard, query field, results grid, category filters — and every transition between them is code you write. The failure mode is a press that does nothing, which reads to a user as the app being broken rather than as a missing handler.
Texture memory
Every result tile carries a poster, and every poster is a WebGL texture resident in GPU memory. A grid of thirty tiles is thirty textures; scrolling adds more.
Lightning provides texture management primitives but the retention policy is yours. Too aggressive and users see pop-in as posters reload on scroll-back. Too lax and you crash on the low end of the device matrix — and the low end of a TV device matrix is genuinely low.
Fan-out sharpens this. Multiple content types means multiple poster aspect ratios and sizes arriving together, so the memory cost of a result set varies with what the query happened to match.
The hard part: a ranking policy the backends couldn't give me
The endpoints could each tell me what was most relevant within their own catalog. None of them could tell me whether a movie beat a person, because none of them could see the other's results. That judgment had to live in the client, and it had to be made on two or three characters of input.
What I settled on was deliberately simple. Exact title match first, regardless of source. Then movies and series. People always last.
The reasoning behind the first rule is that on a short prefix, an exact match is the only high-confidence signal available. If someone types "up" and there is a film called Up, the probability that they meant something else is low enough to ignore. Everything after that is inference; an exact match is not, so it goes above the inference.
The second rule follows from intent. On a streaming app the user is looking for something to watch. Movies and series are the destination; everything else is a route to them. Ordering by content type rather than by score is technically a worse use of the relevance information available, and it is the right call anyway, because the relevance information is weakest precisely when the ordering matters most.
The third rule is the one I deliberately got wrong. People are demoted even when the match is exact — someone typing an actor's name in full still sees that actor's films before the actor. That is incorrect for the user who specifically wanted the person page, and they pay for it by traveling further with the D-pad. I accepted that cost because the failure is recoverable in a few presses, whereas the alternative failure is not: if a person card can take position one, then a user searching for something to watch lands on a card that plays nothing, and the fix is to back out and start over. Asymmetric costs justify an asymmetric policy.
Render timing worked the same way — pick a cheap failure over an expensive one. Results paint progressively as each endpoint returns, so first paint is fast and the screen never sits empty waiting on the slowest source. But ordering locks at 400ms. After that, late arrivals append in place rather than sorting into position.
Four hundred milliseconds is roughly where a screen change stops reading as "still loading" and starts reading as "the thing moved." Before that threshold the user is still absorbing the screen and reordering is invisible. After it, they have oriented, and moving their highlight is a betrayal of a model they have already built.
The cost is real and I knew it going in: an endpoint returning at 600ms with a genuinely better match gets appended below results that deserve it less. I would rather serve a slightly worse ordering that stays still than a better one that moves. On a device where the only pointer is a highlight the user is steering blind, stability is a correctness property, not a polish item.
