A few weeks ago, my company, QuestionPro, provided a subscription for GitHub Copilot. I hadn’t used AI to write code entirely and had never “vibe coded” before. So, I thought I’d experiment with it.
TL;DR: I’m not going to vibe code again!
My Failed Experiment
Maybe it was my lack of vibe code experience, but I couldn’t get it to work very well. I wanted to create a sync manager to sync my local data to the cloud. I’m not a backend engineer, so I described to the AI what I wanted to do. It gave me a detailed PRD (Product requirement document), which I then fed to my agent—in this case, Copilot. It created some files, but they didn’t work. I shared the error, it fixed something, and then it failed to work again. After some back and forth, it …
A few weeks ago, my company, QuestionPro, provided a subscription for GitHub Copilot. I hadn’t used AI to write code entirely and had never “vibe coded” before. So, I thought I’d experiment with it.
TL;DR: I’m not going to vibe code again!
My Failed Experiment
Maybe it was my lack of vibe code experience, but I couldn’t get it to work very well. I wanted to create a sync manager to sync my local data to the cloud. I’m not a backend engineer, so I described to the AI what I wanted to do. It gave me a detailed PRD (Product requirement document), which I then fed to my agent—in this case, Copilot. It created some files, but they didn’t work. I shared the error, it fixed something, and then it failed to work again. After some back and forth, it finally worked (or so I thought).
And guess what? It had created four files with around 600 lines of code, most of which were unnecessarily complex, and I was too ignorant to understand them.
The Nightmare of Maintenance
The code worked, but a few days later, I needed to add a new feature. I asked the AI, added the feature, it failed, and after more back and forth, it finally succeeded by adding 300 more lines of code and two new files.
Click to see the original code generated by AI
/** Part of a 467-line file generated by AI */
async function processSyncItem(item: ISyncQueueItem): Promise {
try {
const tableConfig = getTableConfig(item.tableName);
if (!tableConfig) {
console.error(`Table config not found for ${item.tableName}`);
return false;
}
if (tableConfig.skipSync) {
// This table is marked to skip sync, so we'll just remove it from the queue
return true;
}
const { dexie, supabase: supabaseTable } = tableConfig;
switch (item.operation) {
case "insert":
case "update": {
// For both insert and update, we get the record and upsert it to Supabase
const record = await dexie.where("uuid").equals(item.recordId).first();
if (!record) {
console.error(
`Record ${item.recordId} not found in ${item.tableName}, skipping sync`,
);
return true; // Remove from queue since record doesn't exist
}
const formattedRecord = formatDates(record);
const { error } = await supabase
.from(supabaseTable)
.upsert(formattedRecord);
if (error) {
// Check if it's a foreign key constraint error
if (error.code === "23503") {
console.warn(
`Foreign key constraint error for ${item.tableName} (${item.recordId}). This operation requires related records to be synced first.`,
);
// Return false but don't fail the entire sync
// This will keep the item in the queue for next sync
return false;
}
console.error(
`Error syncing ${item.operation} for ${item.tableName}:`,
error,
);
return false;
}
return true;
}
case "delete": {
// For accounts table, we need special handling
if (tableConfig.name === "accounts") {
// Check if there are transactions using this account in Supabase
const { data: linkedTransactions, error: checkError } = await supabase
.from("transactions")
.select("uuid")
.or(
`account_id.eq.${item.recordId},to_account_id.eq.${item.recordId}`,
)
.limit(1);
if (checkError) {
console.error("Error checking linked transactions:", checkError);
return false;
}
if (linkedTransactions && linkedTransactions.length > 0) {
console.warn(
`Cannot delete account (${item.recordId}) with linked transactions. The transactions must be deleted first.`,
);
return false;
}
}
// Delete the record from Supabase
const { error } = await supabase
.from(supabaseTable)
.delete()
.eq("uuid", item.recordId);
if (error) {
// Check if it's a foreign key constraint error
if (error.code === "23503") {
console.warn(
`Foreign key constraint error for ${item.tableName} (${item.recordId}). This record is referenced by other records.`,
);
return false;
}
console.error(`Error syncing delete for ${item.tableName}:`, error);
return false;
}
return true;
}
default:
console.error(`Unknown operation ${item.operation}`);
return false;
}
} catch (err) {
console.error(`Error processing sync item:`, err);
return false;
}
}
Now, one thing as a developer, never leave any code you don’t understand. The thing is whenever you make a change, you should always have the idea of why you made it and what the code actually do. So, I learned the APIs, chatted with the AI about the paradigms of syncing and common issues, and then I rewrote the whole thing myself. The result was two files and less than 250 lines of code. It was much simpler, and now I actually know how my syncing works. Now, when I have to update a feature, it’s a matter of minutes.
Click to see my simplified version of the sync code
/** Part of a 105-line file I wrote myself */
async function pullRemoteChanges(table: ITableConfig) {
const lastSync =
localStorage.getItem(`lastSync_${table.name}`) || "1970-01-01T00:00:00Z";
try {
const { data } = await supabase
.from(table.supabase)
.select("*")
.gt("updated_at", lastSync);
if (!data) throw new Error("No data found.");
for (const record of data) {
const localRecord = await table.dexie
.where("uuid")
.equals(record.uuid)
.first();
if (!localRecord) {
await table.dexie.add({ ...record, _synced: 1, deleted_at: 0 });
} else {
const merged = mergeRecords(localRecord, record);
await table.dexie.where("uuid").equals(record.uuid).modify(merged);
}
}
localStorage.setItem(`lastSync_${table.name}`, new Date().toISOString());
} catch (error) {
console.error(`Pull error (${table.name}):`, error);
}
}
Yes, writing all the functions myself took about twice as long, but it was worth it in the long run. And to be honest, watching the agent work was incredibly boring. In contrast, writing the code myself was much more fun.
FYI, don’t judge my code quality; I’m still learning. The point is that I understand what I wrote, and I can maintain it.
How to use AI: My take
Personally, I think that as a software professional, you should only “vibe code” as a hobby or an experiment. Most of the time, you must use your own NI (Natural Intelligence). Here are some of my suggestions for how I use AI to code:
- For autocompletion. AI tools like Copilot or Supermaven can be great autocompletion partners. They index your entire codebase and suggest code. The best part is when they suggest something tedious, like a list of names or a function to convert text to sentence case. These are things you already know. However, I don’t like multi-line suggestions. Just when you’re thinking, “Okay, I need to write this and that,” boom—it gives you a suggestion. Some might say that’s a good thing, right? But in reality, every suggestion can make you a weaker problem-solver. That’s why I prefer tools like Neovim that only show single-line suggestions. I’d suggest you try Zed and it’s subtle autocompletion.
Note: For developers just starting out, I strongly recommend turning off AI auto-suggestions. Over-reliance on them can drastically hinder the development of your own problem-solving capacity.
- As a brainstorming partner. One of the best things about AI is that it’s a great conversational partner. So, whenever you feel the urge to ask an AI for code, stop. Instead, ask it how you can solve the problem. Don’t ask it to write the code for you; ask for guidance on how you can write it yourself. This way, you will actually understand the code. An AI is tireless, so ask it even the “dumbest” questions you have. FYI, no question is dumb when you’re learning. (In life? Well, that’s another story!)
Act as a technical mentor. I want to build a sync manager that syncs my local IndexedDB data to Supabase. How should I approach this problem?
To reinforce your learning. When you write code by hand, you truly know what you’re writing. It gets etched in your brain. In my childhood, teachers used to say that writing something once is more valuable than reading it ten times. The major pain point of AI code comes later: when you need to update a feature, you find yourself in an oblivious state. And what do you do? You ask the AI to fix it again! But if you had written the code yourself, you can often pinpoint the exact line you need to update. So, don’t just read the code an AI writes; write your own. 1.
For the boring stuff. Another great use for AI is code review. After completing a module, share the code with an AI and ask if it sees any scope for improvement or any visible bugs. Analyze its suggestions and implement improvements as you see fit. AI is also excellent for writing documentation and test cases. In summary, give the boring tasks to the AI and keep the interesting ones for yourself.
Act as a code reviewer. Here is my code for a sync manager that syncs local IndexedDB data to Supabase. Can you review it and suggest any improvements or potential bugs?
- As a research buddy. Finally, I love programming. The best part is that with just a few lines of text, you can create amazing things from nothing. If you are someone who wants to excel, push yourself not to use AI to write your code or solve problems for you. Instead, use it as a friend or that helpful colleague who comes over and says, “Hey, have you tried this approach or that trick?” Most importantly, make the AI your research buddy.
How does array sorting work in JavaScript? Can you explain the different sorting algorithms and their time complexities?
Those are my thoughts on AI. There are a lot of things to learn, to build, and to enjoy in this field. Don’t make AI your frenemy; make it your friend.
What about you? How do you balance using AI tools without letting them take over your thinking process? Share your experiences!