Almost every Roku project starts as a blank canvas. You create the scene, get the first view on screen, wire up focus, and before long you run into a question every Roku team eventually has to answer: how are we handling navigation?
At first, the answer is usually a couple of functions. Show this view. Hide that one. Keep a reference to the previous screen. Maybe push a few things onto an array so the back button works. Then the app grows, and suddenly some views need to stay alive while others need to be destroyed. Focus has to be restored. Deep links need to work. Authentication can interrupt navigation. A screen may need data before it can open. Back still has to do what the user expects. That little pile of navigation code gets messy fast.
Part of the problem is that navigation is also where project-specific logic tends to accumulate. One client’s authentication flow gets mixed in with another app’s paywall rules, deep link handling, or screen lifecycle behaviour. Without a clear separation between how navigation works and what the application needs, those concerns start getting welded together.
Reuse becomes a problem too. Most experienced Roku developers have solved some version of navigation and stack management before, so the obvious temptation is to carry that solution into the next project. We’ve come to call this the “borrowed code” problem. You may have written the code yourself, but if it was written for another client, employer, or product, you can’t simply drag it into the next application just because it solves the same problem. Even when reuse is possible, code filled with assumptions from one application usually doesn’t transplant very well.
And copying code only saves time on day one. After that, the costs start hiding. Every copy drifts on its own, so a fix made in one application never reaches the others. The intellectual property questions don’t go away just because the code moved. And without proper abstraction, each transplanted version picks up new assumptions that make the next transplant even harder. So you either rebuild it again, or you create something that actually lives outside any one project.
Can Navigation Be Deterministic and Flexible?
That’s why we started sgRouter. There wasn’t a specification sitting in front of us describing what it should look like. The original question was much simpler: could navigation and view stack management in SceneGraph exist as something genuinely reusable instead of being rebuilt inside every application?
One idea we liked early on was URL-based navigation. Initially, that was little more than something we thought would be useful. It became more interesting as we talked to other developers and looked at how Roku applications were being built. A lot of companies share APIs between web, mobile, and TV applications. In some of those systems, URLs or URL-like paths are already being passed through the API. We were seeing navigation information arrive at Roku applications without the application having a common way to treat that path as navigation. So we started exploring it.
Beyond that, we didn’t have a blueprint. As the work progressed, a harder question emerged: can navigation on Roku actually be deterministic while still allowing applications to behave differently? If an application asks to navigate somewhere, can the sequence of events be predictable? When does the current view close? When does the next one begin loading? What happens if that loading is asynchronous? What happens if the user presses back before it finishes? Where should authentication or entitlement checks happen? Can navigation be stopped or redirected before the destination view opens? Which views should be destroyed? Which should be suspended? What should happen to the stack afterward?
The easy way to make something predictable is to remove options. That wasn’t useful to us. Roku applications don’t all navigate the same way. The problem was finding a predictable sequence without forcing every application into the same behaviour.
There were two other constraints sitting behind all of this. Could we do it without creating a framework that eventually became harder to work with than the problem it was supposed to solve? And could we do it without introducing unnecessary performance costs on Roku hardware? Those turned out to be much harder questions than they looked.
Starting Without an Answer
We didn’t start sgRouter knowing what the solution would be, or whether the approach would hold together once we started dealing with real navigation scenarios.
Async behaviour was one of the first major problems. Roku handles asynchronous work differently from platforms built around async/await. A common SceneGraph pattern is to start some work, observe a field, and react when the result arrives. That works. It gets considerably harder when navigation itself can change while that work is happening. A user can press back while a screen is still preparing. Another navigation can supersede the first one. An observer can fire after the application has already moved somewhere else. Now the navigation system has to understand whether the result that just arrived is still relevant.
Our first attempt tried to stay close to traditional Roku patterns. Each view exposed an observable field that it would trigger when it was ready, and the navigation code waited for that signal before continuing. It sort of worked. It also created exactly the type of coordination problem we were trying to get away from. Readiness was being announced by individual views in slightly different ways, while cancellation or another navigation could happen in between. We couldn’t see a reasonable path from that model to deterministic navigation, so we scrapped it.
That failed approach was one of the things that pushed us toward building sgRouter around the RokuCommunity promises library instead. Promises gave us a way to represent navigation as a sequence of asynchronous operations rather than a collection of observers that happened to fire in the right order. Even then, we didn’t know whether the model would scale to all of the lifecycle and stack-management scenarios we wanted to support. For a while, the answer really was: we’re not sure yet.
What Started to Work
The first idea that held up was that navigation should be described by the application rather than manually scripted by every screen. Instead of spreading functions around the application that show, hide, create, and destroy views, the application defines routes. A route describes what path maps to a component and the conditions around navigating to it.
For example:
sgRouter.navigateTo("/details/movies/42")
Or the application can navigate by name:
sgRouter.navigateTo({
name: "movieDetail",
params: { id: 42 }
})
The application says where it wants to go. The navigation layer figures out how to get there.
If this looks familiar, that’s deliberate. The public-facing API in sgRouter uses concepts familiar from Angular Router on the web. Routes, guards, asynchronous resolution, and lifecycle events will all be recognizable to developers who have worked with Angular. That was a decision about learning curve, not implementation. The concepts transfer well; the underlying mechanics do not. SceneGraph’s lifecycle, rendering behaviour, asynchronous patterns, and lack of native async/await meant we still had to work out how those ideas could behave reliably on Roku. Inventing new vocabulary for the same ideas would only have made sgRouter harder to pick up. If you’ve used Angular Router, much of the API should feel familiar. What happens underneath it is very much a Roku problem.
The bigger step was turning navigation into an explicit pipeline. Instead of lifecycle events emerging from whichever view happened to be active, a navigation request moves through defined stages for route recognition, guards, activation, asynchronous resolution, completion, cancellation, and failure. Those stages are observable. That gave us something we didn’t have with the observer-based approach: an order of operations we could actually define, test, and reason about. When someone asks what happens during navigation, we can give a much better answer than “it depends on the screen.”
The sequence stays consistent while the behaviour inside that sequence remains configurable. One route might need authentication. Another might load data before opening. One view may be destroyed when the user leaves it while another stays alive. The router coordinates the sequence, and the application still decides what those individual screens need to do.
That distinction has been important. Authentication is a good example. sgRouter doesn’t know what “logged in” means. It shouldn’t. Instead, a route can have a guard. That guard can allow the navigation, reject it, or redirect somewhere else. The application owns the business rule and sgRouter owns when that rule is evaluated. That gives client-specific logic somewhere to live without embedding it inside the navigation engine.
The same thinking shaped the view lifecycle. A view in sgRouter has defined points where it can prepare for opening, respond once it is active, react to suspension or resume, and clean up before closing. Some of those hooks aren’t just notifications. They’re contracts. beforeViewOpen can return a promise, and the router waits for it. That’s where a screen can perform work it needs to complete before opening. If a route allows reuse, an existing view can receive onRouteUpdate with new parameters instead of being torn down and rebuilt. The same detail screen can move from one title to the next without paying the construction cost again.
The main lifecycle hooks receive the current route snapshot: the route, its parameters, query values, and how the view was reached, whether by a push, a back, a resume, or a redirect. onRouteUpdate is deliberately different. It receives both the old and new route so the view can respond to exactly what changed. That last part matters more than it looks. A view that knows how it was reached, or exactly what changed, can behave accordingly instead of guessing from side effects.
The Stack Was Its Own Problem
View management brought another set of experiments. Destroying every screen when the user navigates away is simple, but it can be expensive. Keeping everything active creates a different performance problem. We needed more than one behaviour. A view can be removed from active rendering while its state is retained, and screens that shouldn’t survive can still be destroyed normally. That eventually led to keepAlive, suspension and resume lifecycle events, and more explicit control over how views behave in the stack.
Checkpoints, on the other hand, came from outside the project entirely. When we reached out to the Roku developer community for feedback, one developer described an app full of funnels: multi-step flows putting several screens onto the stack, each with its own rules, all needing to return to a known point when the flow ended. Our first thought was nested flows, a stack within the stack. The more we looked at it, the more of a can of worms it became. Nested flows meant nested lifecycles, nested back behaviour, and a second set of rules layered on top of the first.
Checkpoints turned out to be much cleaner. An application can mark a point in the navigation stack and later unwind directly back to it. The router handles the views in between according to their lifecycle rules rather than requiring the application to manually reconstruct the stack. Checkout, onboarding, authentication: any funnel can end with a single call instead of a chain of manual backs.
These features weren’t on a checklist when we started. They came out of trying to answer the same question under increasingly complicated scenarios: can the result of a navigation operation remain predictable?
What sgRouter Refuses to Own
One of the more important design decisions has been deciding what not to put into sgRouter. It doesn’t fetch application data. It doesn’t render your UI. It doesn’t decide what authentication means. It doesn’t contain your product’s business rules. And it doesn’t dictate how an individual screen manages its own focus. Its job is coordination.
That boundary is what allows the navigation code to stay separate from the assumptions of any individual client or application. It’s also how we’re trying to avoid creating the exact kind of framework we didn’t want to work with ourselves.
Where the Question Stands
So can navigation on Roku be deterministic and flexible? Our answer today is a careful yes. Every navigation moves through the same sequence. A screen that needs data can wait for it. A guard can stop or redirect a request before a view ever exists. Back behaves according to rules the application declared instead of rules it improvised. And none of it forces two applications to navigate the same way.
Our answer to how much the router should own has held up so far too, largely because of what we left out. sgRouter coordinates. The application keeps everything else.
Performance remains the constraint we watch most closely. Suspension, view reuse, and keepAlive exist because Roku hardware doesn’t forgive waste, and we’re not done tuning any of it.
We won’t claim every question from the start of this post is settled. What we can say is that the answers now live in one place, tested against real scenarios, instead of being trapped inside any single application.
Why It Doesn’t Belong to Us
That led to one other decision. sgRouter started at TKSS Software, but keeping a reusable navigation layer locked inside one company would have recreated the same problem we were trying to solve. So the project moved to RokuCommunity, where it now lives as the open source @rokucommunity/sgrouter package. It can be used without pulling code out of another client’s application, and it can be challenged, changed, and improved by developers running into the same problems.
The work isn’t finished. We’re still exploring what deterministic navigation on Roku should look like, where the boundaries should sit, and what happens as increasingly complicated applications are pushed through the model. But at least the next Roku project doesn’t have to start this part from nothing.
If you’re building Roku applications and want to try it, kick the tires. File an issue. Tell us what breaks. Those are usually the interesting parts.