Articles / Fastify backend for cute kittens (aura8)
We developed an app where users can swipe through images, and based on their likes, we generate new ones. Below is a story about the interesting challenges we faced during the development of this application.
Choosing the ID
The hardest part of development is naming variables and functions. Even harder is coming up with an ID format for entities.
In this project, there will be many images and many users, so IDs in GUID or UUID format are too taxing for table indexing and expensive in terms of backend load. We needed an ID that is "incremental" — meaning a new ID is added to the end of the table index — while remaining unique at the same time.
We settled on an ID like 19bc19e00ab-eh5kkeh99q7, where the first half is the time converted to HEX, and the second half is a random set of characters. The hyphen between these two halves doesn't serve a functional purpose, but it makes the ID more visual and easier to read.
function generateID(): string {
const timestamp = Date.now().toString(16);
const randomPart = generateRandomSequence( 11 );
return timestamp + '-' + randomPart;
}
This ID is conditionally incremental, as it changes every millisecond, and it's sufficiently unique.
Should we use a "created" field in tables?
The chosen ID format has a small perk: you don't need a "Creation Date" field in the table because the date can be decoded directly from the ID. Yes, it’s slightly inconvenient to build filters for selecting by creation date, but the absence of an extra field outweighs the selection inconveniences.
Bloom Filters
The app is expected to work under high load: thousands of simultaneous sessions and tens of thousands of images to build user feeds. At the same time, the backend must be very "cheap" in terms of CPU and memory consumption. The most resource-intensive task is keeping track of which images a user has already seen and selecting the next ones for them. Obviously, a "traditional" solution via SQL queries to the database is too heavy, slow, and inapplicable here. So, we use a Bloom filter.
If you're not in the loop, a Bloom filter is a very fast filter that says with 99.9999% probability that a specific ID is NOT in an array of tens of thousands of IDs. In terms of CPU and memory load, the savings are colossal.
App Authorization
We don’t want to force users to authorize because users hate authorization. Besides, apps without authorization pass store moderation more easily.
But we still need to identify users. So, upon installation, we generate an ID and save it in the app.
What if some enthusiasts start generating these IDs and sending them to the backend? We add a limitRate and validate new IDs for correctness. I’ll mention limitRate below, but here is the logic for checking new users:
1. At backend startup, we load all existing user IDs into a Bloom filter.
2. For a user addition request, we first check the new ID's correctness at the Fastify level by declaring a JSON schema for the request:
fastify.post( '/backend', {
schema: {
body: {
type: 'object',
required: [ 'device_id' ],
properties: {
device_id: {
type: 'string',
minLength: 23,
maxLength: 23,
pattern: '^[a-z0-9]{11}-[a-z0-9]{11}$'
}
}
}
}
3. If the new ID passes the Fastify check, we check it against the user Bloom filter. If the ID is not in the array, it’s a new user.
4. We verify the new user's ID for correctness.
The first half of the ID is the encoded date; this date must fall within the period between our project's launch date and today.
How to check the second half, which is a random set of characters? What if enthusiasts generate IDs like 19bc19e00ab-aaaaaaaaaaaa? We check the second half for entropy; it must be greater than 2.
function getEntropyString( str: string ): number {
const frequencies = new Map();
for (const char of str) {
frequencies.set(char, (frequencies.get(char) || 0) + 1);
}
return [...frequencies.values()].reduce((acc, count) => {
const p = count / str.length;
return acc - p * Math.log2(p);
}, 0);
}
5. If the new ID is valid, we add it to the user Bloom filter and send an INSERT to the user table without waiting for the INSERT to finish. Yes, there's a problem: something could go wrong with the insert, and ideally, we should complete the insert and handle errors first. But that's taxing for the backend. Мы accept the risk of losing a user after a restart. By "losing a user," we mean that after a restart, this user will be treated as new, and their swipe history won't be saved.
Static Content via Nginx
The backend collects like stats, generates new images, and puts them in folders with a Year — Month — Day structure. In the app, images are loaded via URL. But how to serve static content with minimal server load? Fastify can do it well, but it’s even better to run Nginx on the server under a separate domain to serve content directly from the folder where the backend app stores the images.
Image Series for the User
We generate a new series of image URLs for the user in batches of 10. We add these IDs to a "Seen Images" Bloom filter for each user. When generating a new series, we check if the image was in previous batches using that filter.
Thus, the backend and mobile app communicate in series of 10 images.
1. At app startup, two series of 10 are loaded.
2. Every 10 likes, the app sends reactions to the backend and receives a new series of 10 images.
While the user is liking a series of 10 images, the app sends reactions for the previous 10 and fetches the next 10 images.
Rate Limiting
Fastify has a rateLimit plugin. It can be configured to use Redis, but if you have only one backend server for now, you can do without Redis and store everything in Node.js memory.
Validating Image Likes
The mobile app collects reactions and sends them to the backend in batches of 10. One reaction is an Image ID and a like/dislike. Again, how to solve the problem with enthusiasts who might flood the backend with fake reactions? We add a limitRate and validate reactions with the following logic:
1. User ID and Image ID must pass Fastify schema validation. Reactions (like/dislike) can also be validated as an enum in the schema.
2. User ID must pass the "Existing Users" Bloom filter check.
3. Image ID must pass the "Seen Images" Bloom filter check for that user.
4. Just in case, we check likes for "alternation." If a user just swipes everything in one direction or alternates left-right-left-right, the batch is untrustworthy and ignored:
function trustLikeInBatch10( batch: string[] ): boolean {
if( batch.length != feedLimit ) return false;
const likes = batch.filter( v => v === 'like' ).length;
if( likes === 0 || likes === feedLimit ) return false;
const switches = batch.slice(1).reduce( ( acc, val, i ) => acc + ( val !== batch[ i ] ? 1 : 0 ), 0 );
if( switches >= 8 ) return false;
return true;
}
5. If all checks pass, we send an INSERT to the reactions table without awaiting completion. Furthermore, we don't use CONSTRAINT FOREIGN KEY for User ID or Image ID in the reactions table to reduce DB load during inserts. Although the lack of FOREIGN KEYs feels very unusual.
Masking Fastify Validation Errors
To make life a bit harder for "curious" enthusiasts, we mask Fastify validation errors. By default, Fastify tells you exactly what went wrong (length, pattern, etc.). To avoid giving hints, we override the error handler:
fastify.setErrorHandler((
error: FastifyError,
request: FastifyRequest,
reply: FastifyReply
) => {
console.error( 'setErrorHandler', error );
countErrorStatistics();
reply.code(200).send( 'Hello World!' );
});
The mobile app must be ready to receive this masked error instead of a standard one from the backend.
Creating a Type from a String Array
A small problem with an elegant solution. Let's say you have an array of strings (e.g., image states: new, broken, ready, etc.). You also need a TypeScript type that only allows these values. You can do both at once:
// Usually it's like this:
const imageStateArray1 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ];
type tpImageState1 = 'new' | 'broken' | 'gpt' | 'ready' | 'download' | 'done' | 'bad';
// or better yet:
const imageStateArray2 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ] as const;
type tpImageState2 = ( typeof imageStateArray2 )[ number ];
Project Summary
Essentially, the app can live almost without a database. After startup and loading IDs into memory, the only DB tasks are INSERTs without await. Inside the controller, it’s just math and Bloom filters.
What's Next
In future posts, I’ll talk about the prompt sequences for image generation and the mobile app itself.







