Articles · App ExamplesUpdated September 2026

You can add likes to an app with one table and one unique constraint.

A like is a row, not a number. Store one row per person per item, make that pair unique so nobody can like twice, and the like count becomes a question you ask of those rows rather than a total you have to keep correct. That much takes an afternoon, which is why social app builders treat a like button as a starter feature. The honest limit is on the reading, not the writing: counting those rows is what gets expensive, and it gets expensive across a feed long before any single post is popular.

This page covers the table and the constraint, how to make the tap feel instant, what breaks first and roughly at what size, and how a reactions feature or an upvote feature differs from a plain heart. Almost none of it is about the button.

See what breaks first

The short version

Writing the like is the easy half, reading the count is the other one.

Two operations sit behind a like button in a mobile app and they cost very different amounts. The write is tiny: one row inserted or one row deleted, one index touched, done. The read is the one that multiplies, because every card on screen wants two answers, the running total and whether the person looking at it has already tapped.

Likes features usually work fine for a year and then get slow, and it is almost never the write that gave up. It is twenty cards each asking for a count, or one post that got popular and now has every device in the country trying to increment the same row at the same moment. Those are two different problems with two different fixes, and doing the second one first wastes a week.

One row per person per item

The table needs three useful columns and no more: who, what, when. A row exists if the person likes the item and does not exist if they do not. There is no boolean on the post to keep in step with anything, and no repair job for when it drifts, because the row is the state. Liking is an insert. Unliking is a delete.

Put a unique constraint on the pair of who and what. This is the single most important line in the whole feature, because a like button is the easiest control in an app to fire twice: a slow network, an impatient tap, a retry buried in your own networking code. With the constraint in place you can insert without checking first. PostgreSQL turns that into one statement: its ON CONFLICT clause replaces the unique violation error with an alternative action, and DO NOTHING means a duplicate like is quietly ignored instead of raised. No read, no race, no if statement in your app that two phones can both pass at once.

Resist the urge to keep unlikes around as soft deletes. The moment an old row lingers with a deleted flag on it, the pair is no longer unique and you have given away the one guarantee the design was built on. If you genuinely want the history, write it to a separate events table and leave the likes table holding only what is true now.

Everything else follows from that shape, which is the usual move in mobile app database design: pick the table that cannot hold a contradiction, then let the queries stay boring.

PostgreSQL documentation, INSERT and the ON CONFLICT clause

What breaks first, and roughly at what size

The read side gives up before the write side, and it does it quietly. A feed with twenty cards asks for twenty counts plus twenty checks of whether you already liked each one. That is forty round trips for one screen, repeated every time the list refreshes. Nothing about that depends on any post being popular, so it is the first thing to go and it goes at an embarrassingly small size.

The fix is one query, not forty. Fetch the counts for the visible item ids in a single grouped query, and fetch the current person's likes for those same ids in a second one. Two round trips per screen instead of forty. Most apps that feel slow on a list of posts are slow for exactly this reason.

The write side breaks later, and on one item rather than across the feed. Keep a cached total on the item row and every like updates the same row, so every liker queues behind the last one. Firestore puts a number on that shape: a single document cannot be updated at an unlimited rate, frequent increments produce contention, and the sharded counter pattern in its own documentation is built from shards that each take roughly one increment per second, with throughput rising in line with the shard count. The documented limitations are the useful part: too few shards and writes retry, too many and reads get slower and more expensive, so a roll-up document updated on a slower cadence is the usual compromise.

A row in a relational database will take considerably more than one write a second before it complains, and we have not measured where that lands, so do not read the Firestore number as your limit. Read it as the shape of the failure. The order of work follows from that: count rows, batch those counts into one query per screen, cache a total only when a single item is genuinely hot, and shard only after that. Most apps stop at step two. The general version of this argument, counting at scale, goes further than a page about likes should.

Firebase documentation, distributed counters in Firestore

Try it

What one screen of likes costs

Set both numbers.

40 round trips a refresh, or 2

A count and a has-liked check per card is 40 queries every time the list reloads. Two grouped queries return the same data. At 2 likes a second the busiest item is past the roughly one increment per second a single Firestore document handles, so a cached total there needs about 2 shards.

Reactions and upvotes are the same table with one more column

A reactions feature in an app is the likes table plus a type column. The only real decision is what stays unique. Keep the constraint on person plus item and everyone holds exactly one reaction, so swapping a heart for a laugh is an update to an existing row. Move it to person plus item plus type and a person can hold several at once. Pick one before you write the table, because changing it later means cleaning up data you already have.

An upvote feature is a like with a sign. Up and down become two values of the same column and the score is a sum rather than a count. That one difference matters more than it looks: a score can go down, so it cannot be kept by an increment-only counter, and it stops working as a proxy for how many people saw the item.

Keep the reaction set small and fixed. Five or six types, chosen by you, stored as short strings rather than as the emoji characters themselves, is the version that survives contact with real users. An open emoji picker looks generous and turns your analytics into noise, your moderation into somebody's job, and your notification copy into a sentence nobody can write. Adding a type later is easy. Removing one after people have used it is the awkward direction.

Be honest about what a reaction tells you. It says somebody saw the thing and felt mildly something. It does not say what they thought, which is a different feature with a different table and a much larger surface: if conversation is the actual goal, add comments first and treat reactions as the cheap layer sitting on top of it.

Make the tap instant, then stop building

Flip the heart in local state before the request leaves the phone, then reconcile when the answer comes back. That is the whole trick, and it is the difference between a like button that feels native and one that feels like a web page from 2009. If the request fails, put the heart back and say nothing louder than that. A toast for a failed like is worse than the failed like.

Guard the double tap in the client as well as in the database. The constraint protects your data, but it does nothing about the fifty requests you will send when somebody taps fifty times. Disable the control while a request is in flight, or hold one pending state per item id. Offline is the same problem with a longer fuse: a like tapped with no signal has to queue and replay, and replaying a toggle is not the same as replaying an insert, so queue the intended end state rather than the action.

Three things people build too early and regret. A push notification on every like, which is the fastest known way to get an app muted. A full list of everyone who liked something, which is a separate screen with its own pagination and its own privacy conversation. And a leaderboard, which turns a small friendly feature into something people work at. Ship the heart, the count and the undo. Add the rest when somebody asks for it twice.

Five ways to get a like count, and what each one costs

ApproachCount is exactSurvives a hot itemExtra write per likeWhat it takes
Count rows once per cardYesNoNoone query per card
One grouped query per screenYesNoNoone query per screen
Cached total on the item rowYesuntil the row gets busyYesa transaction or a trigger
Sharded counter plus roll-upwhen summedYesYesshards and a scheduled sum
Approximate total on a timerNoYesNoa scheduled job

Building likes into an app you own

None of this is hard to write. It is fiddly enough that it gets put off, and then it lands late with a bug in the double tap, a count that drifts and a notification nobody asked for. Writing the feature down first, as one table, one constraint and two queries, gets you most of the way there before any code exists.

Newly is an AI app builder. You describe the app in plain English and it writes a real React Native and Expo project you own, runs it on a cloud iPhone or Android simulator while it builds, and ships it to TestFlight and to Google Play internal testing. It is $25 a month and there is no free plan. It does not bring a backend with it, so the likes table lives in whatever database you pick, and the code comes out as a ZIP or through two-way GitHub sync from the Deploy tab when you want to tune the queries by hand.

Say the specifics when you describe it. One row per person per item. A unique constraint on the pair. An optimistic heart that reverts on failure. Counts fetched once per screen rather than once per card. A builder told the shape builds the shape. A builder told only to add likes gives you a boolean on the post, and that boolean is the bug you fix later.

Questions people ask about adding likes and reactions

Create a table holding one row per person per item: the user id, the item id and a timestamp. Put a unique constraint on the user and item pair so nobody can like the same thing twice. Liking inserts a row, unliking deletes it, and the like count is a query over those rows. There is no boolean on the post to keep in step, which is the point.

Describe the feature, not the button

Write down the table, the constraint and the two queries a screen needs, then build the app around them.

Start building