My dad has run his own business for years, and for most of that time, he paid a monthly subscription for an invoicing tool that he mildly resented every single time he opened it. Dated interface, thin inventory features, the kind of software that clearly hadn't been touched by anyone who actually works in a shop in a long time. He knew I built software for a living. At some point, over dinner, he half-joked that I should just build him something better. I don't think he expected me to actually do it. I did it anyway, and it turned into Keipy, and it ended up teaching me one of the most stubborn lessons I've internalized as an engineer, one I now treat as close to a rule as I let myself have.
The assumption I almost shipped with
When I started sketching Keipy, my first instinct (and I think this is most people's first instinct) was to build the "normal" version first. Quoting, invoicing, stock tracking, all talking straight to a server, all assuming a connection is just there, the way it basically always is on my own laptop, sitting in my own apartment, on my own reliable wifi. Get that working end to end, ship it, and then, once it's stable, go back and "add offline support" as a follow-up feature. That's the natural order of things. Build the happy path, then armor it.
The thing that stopped me was actually just watching my dad work for an afternoon. His shop's connection drops. Not dramatically, not as some rare edge case: casually, a few times a day, sometimes for a few seconds, sometimes for longer, the way an aging router and a not-great ISP conspire to do in a lot of small businesses that aren't going to spend real money fixing an internet connection that's "basically fine." He'd be mid-invoice, phone in hand, customer standing right there, and the spinner would just sit there. Watching that happen once was enough to kill the plan I'd already half-written in my head. If I shipped the "normal" version first, this exact moment (a customer standing at the counter while a screen hangs) was going to be the first bad experience real users had with something I built for my own father. I couldn't stomach that.
Why bolting it on later doesn't actually work
Here's the part I want to really get across, because I don't think "make it work offline" sounds like a big deal until you've actually tried to retrofit it into something that wasn't built for it. Offline isn't a network problem. It's a data modeling problem, and it touches almost everything once you take it seriously. What does it mean for a write to be "pending"? How do you show a user the difference between "saved" and "saving, please wait, do not close the app"? What happens if two edits to the same invoice happen in different offline windows and then both try to sync? What happens if the app gets killed (not gracefully closed, actually killed, by the OS running low on memory, or the phone dying) mid-write? None of these are things you can duct-tape onto a data layer that was designed assuming the network is basically always there. I've watched other projects try, and the result is always the same flavor of miserable: a write that partially applies and leaves things in a weird half-state, a UI that genuinely cannot tell the user whether something saved or not, a sync queue that quietly forgets what it was doing the moment the app restarts.
So I made a decision early, and it was uncomfortable at the time because it slowed the whole first month of building down: every single mutation in Keipy (every invoice, every quote, every stock adjustment) goes through the exact same offline-aware queue, regardless of whether the network happens to be up in that particular instant. There is no "online path" and "offline path" as two different code branches that I'm hoping stay in sync with each other forever. There's one path. Online is just the state where the queue happens to drain almost instantly, so fast you never notice it's there. Offline is the state where it doesn't drain yet, and sits there patiently, clearly marked as "syncing" in the UI, until the connection comes back, surviving a full app restart in between if it has to, because my dad's phone restarts apps constantly and I was not going to build something that loses his work every time that happens.
Under the hood it's less exotic than it probably sounds: I leaned on the data-fetching layer already in the app to double as the offline mutation queue, backed by local storage for persistence, with a listener watching for the moment connectivity actually comes back. One less moving part than building a whole separate local database just for this. And because invoicing is tied directly to stock in the same action: issuing an invoice decrements inventory and can fire a low-stock warning in that same beat. I didn't want two separate systems for "the thing that happens online" and "the thing that queues offline." One flow, always.
The gap I'm not going to pretend isn't there
I try to be honest on this blog about what's actually finished versus what's "working, with a known asterisk," and Keipy has one asterisk I think about often. Converting a quote into an invoice, offline, involves a read followed by a write: you're pulling up the quote's current state and then creating something new based on it, and that two-step sequence isn't quite as airtight offline as the simpler single-mutation paths like creating an invoice from scratch. It's a real gap. It's documented, not buried, and it's the next thing on my list to close properly rather than something I'm hoping nobody notices.
If there's a lesson underneath all of this that I keep coming back to, it's that for a tool built for someone who might not have a great connection, "offline" can't be a feature. It has to be a property of the data layer from the very first design decision, because the moment you treat it as something you'll add once the "real" version is stable, you've already built the thing that makes adding it properly nearly impossible. And honestly, the smaller, quieter lesson underneath that one is just: go watch the person you're building for actually try to use it, in their actual shop, on their actual internet. I would have shipped the wrong architecture entirely if I hadn't seen that spinner hang in front of an actual customer, in person, before I'd written more than a few weeks of code.