You're done with a project and the product team comes to you and says: "Hey, I turned off my internet and the app died". You stop for a moment and don't recall seeing this requirement throughout the development. You're afraid because whatever you answer will make them frustrated. Well, there's clearly a lack of communication but you're as guilty as them for not having raised that up before.
The Challenge
Our project was a very traditional web app fully dependent on a network connection to work. The app wasn't designed to work offline at all and we hadn't followed any of the offline-first patterns. When a route was accessed, a request was dispatched to get the data needed for that screen. If it succeeded, its result was stored on the local state of the component that was then rerendered. Pretty simple, right? But it was also going against the offline-first good practices. There was a good thing though, the architectural decisions we made. We'll be talking about it in a bit, but let's focus on the problem first of all.
Partial vs Full Offline experience
We faced a clear conflict with the product team that needed resolution ASAP. To product people, enabling offline mode might seem as simple as pressing a button, expecting everything to work out of the box. However, they often overlook that a lack of internet inherently limits the app's functionality. To address this, we scheduled a meeting to clarify their offline requirements. Did they need the entire app to work offline or just certain parts? Should all features be available, or only the essential ones?
This is crucial and my first advice to you: thoroughly understand what the stakeholders want so you can make the right decisions. Failing to get this right initially can lead to back-and-forth discussions. With clear answers, you can determine the right approach for enabling offline capabilities.
In our case, we discovered they only needed offline functionality for a specific part of the app used by one user group. This realization simplified our task by at least 30%. Additionally, most writing operations could be disabled offline, allowing us to avoid the complex process of reconciliation/synchronization. Ultimately, it's about managing trade-offs and finding the best middle-ground solution.
The Data Layer to the Rescue
One of the most powerful patterns when it comes to front-end development is to try to keep your UI as dumb as possible. There are several benefits in doing that such as easy testability, debugging, and the possibility to inject dependencies into your components. This last one in particular was what saved our lives because the data were abstracted into a completely separate layer and we were connecting it with the UI layer through dependency injection. It looked something like this:
So in other words, there was an interface which was the only thing the UI layer knew about and the RemoteDAO
was being injected into it. Well, that being said we could simply create another implementation of the DAO replacing the RemoteDAO
. However, this new implementation would communicate with local sources of data instead of trying to hit the remote endpoints. We would also need a caching step which is when we store the remote data locally, something like this:
The implementation
To show you a practical example, I built a small todo application using Lit + Vite, but it applies to any framework. Think of an app where parents can assign tasks to their children but some tasks need to be done out of home so they might face poor connection or not have any connection at all. That's why we need to leverage offline features.
On the app entry point, I inject my RemoteTodosDAO
instance by using Lit context (similar to React context or a DI container):
@customElement("main-app")
export class App extends LitElement {
@provide({ context: TodosDAOContext })
remoteTodosDAO = new RemoteTodosDAO();
...
}
Then, I'm able to get this instance from any component by doing:
@customElement("todos-page")
export class TodosPage extends LitElement {
@consume({ context: TodosDAOContext })
todosDAO!: TodosDAO;
}
By the way, here's is what the RemoteTodosDAO
looked like:
import axios from "axios";
import { Todo, TodosDAO } from "./types";
export class RemoteTodosDAO implements TodosDAO {
private _httpClient = axios.create({ baseURL: "http://localhost:3000" });
async list() {
return (await this._httpClient.get<Todo[]>("/todos")).data;
}
async get(id: string) {
return (await this._httpClient.get<Todo>(`/todos/${id}`)).data;
}
async save(todo: Todo) {
return (
await this._httpClient.patch<Todo>(`/todos/${todo.id}`, {
...todo,
})
).data;
}
}
Caching data locally
I've mentioned our app was triggering requests as necessary when the user got to some route. But since we must cache all data on the first load, we decided to fetch all needed resources (hitting all endpoints) right when the user accessed the app for the first time. By doing that, we don't have to change anything in the UI layer and we can continue triggering requests when the component is mounted. But instead of reaching the remote source, the app will get the cached data.
As an alternative to hitting all endpoints at once, you could also create a single endpoint on the backend to get the data in a one-way trip. But remember to take performance into account. Are your users mostly on 4G connections or Wifi? How heavy is your data? You'll want to return as little data as possible to not waste resources, return only the most recent ones if your scenario allows so, or even implement pagination.
Now we're going to create a new class to interact with the IndexedDB using Dexie, a wrapper that makes it easier to create schemas. It works the same way as the DAO, but I'm going to name it "Persistor" for convenience. I'm also including the word "progress" in the table name on purpose, I'll explain why in the second part of the article.
const TodosProgressLocalDB = new Dexie("TodosProgress") as Dexie & {
todos: EntityTable<Todo>;
};
TodosProgressLocalDB.version(1).stores({
todos: "&id",
});
export class DexiePersistor implements TodosPersistor {
async saveBatch(todos: Todo[]) {
await TodosProgressLocalDB.todos.bulkPut(todos);
}
}
Next, let's update our RemoteTodosDAO
to receive an instance of the persistor. Given that this app is fairly simple and the GET /todos
endpoint returns all the necessary data, we’ll pass its response to the persistor’s saveBatch
method. However, if your app requires multiple requests interacting with different persistors, you can adjust accordingly.
Essentially, every time the user accesses the app, we will invalidate the local cache by rehydrating it with fresh data. To optimize performance and reduce backend load, consider some invalidation strategies to avoid hitting the backend on every single request.
export class RemoteTodosDAO implements TodosDAO {
private _todosPersistor: TodosPersistor;
constructor(todosPersistor: TodosPersistor) {
this._todosPersistor = todosPersistor;
}
private _httpClient = axios.create({ baseURL: "http://localhost:3000" });
async list() {
const data = (await this._httpClient.get<Todo[]>("/todos")).data;
this._todosPersistor.saveBatch(data);
return data;
}
async get(id: string) { ... }
async save(todo: Todo) { ... }
}
As long as they land in a route within the app, the request will be triggered and the response cached. One of the drawbacks of this approach is that it increases the first-load time. So make sure you show some kind of spinner to your user to indicate the page is loading. Something like this:
@customElement("main-app")
export class App extends LitElement {
todosPersistor: TodosPersistor = new DexiePersistor();
@provide({ context: TodosDAOContext })
remoteTodosDAO = new RemoteTodosDAO(this.todosPersistor);
private outletRef = createRef();
@state()
caching = true;
async firstUpdated() {
try {
await this.remoteTodosDAO.list();
} catch (error) {
} finally {
this.caching = false;
}
makeRouter(this.outletRef.value as HTMLElement);
}
render() {
return html`
${this.caching ? html`<p>Caching resources...</p>` : null}
<div ${ref(this.outletRef)}></div>
`;
}
}
If you check the application tab through the browser inspector, you're going to notice that some data has already been recorded into IndexedDB collections which means we're ready to go to the next and last step to make the reading part work.
Replacing the DAO implementation
All we need to do now is create the new implementation of the TodosDAO
interface, which is going to be used as a replacement for the current one.
export class LocalTodosDAO implements TodosDAO {
async list(): Promise<Todo[]> {
return await TodosProgressLocalDB.todos.toArray();
}
async get(id: string) {
return await TodosProgressLocalDB.todos.where("id").equals(id).first();
}
save(todo: Todo): Promise<Todo | undefined> {
throw new Error("Method not implemented.");
}
}
I left the save method not implemented on purpose as well because I'll be clarifying it in the second part of this article. Alright, let's replace the implementation:
export class App extends LitElement {
todosPersistor: TodosPersistor = new DexiePersistor();
remoteTodosDAO = new RemoteTodosDAO(this.todosPersistor);
@provide({ context: TodosDAOContext })
localTodosDAO = new LocalTodosDAO();
...
}
Now you should be able to turn off your backend server and the app will keep working normally, except for the writing operations.
PWA & Routing concerns
Regarding caching, there are basically two types of resources: assets and data. We just handled data but in order for your app to really work offline you need to cache the static assets such as HTML, CSS, and JS by using a service worker and a manifest file. That's a simple step and if you're using Vite you can achieve that pretty easily with Vite PWA.
You might need to tweak your routes as well if you're using code-splitting. Code-splitting essentially fetches the assets on-demand for a given route to work. However, you don't want your user accessing all the routes in your system beforehand just to get them cached, right? So make sure you disable that, at least for the routes you want to work offline.
Conclusion
Offline mode isn't a trivial topic. It's hard enough when developing a PWA from scratch following offline-first good practices; adding it to an existing app is even harder. However, it's possible to work around and achieve good results if you keep things uncoupled and fully understand the real customer needs. As demonstrated, we managed to get the app functioning offline without altering the UI layer. In the second part, we'll explore how to enable users to perform writing operations offline.
The example code is available on GitHub https://github.com/belgamo/partial-offline-poc
Top comments (0)